initial commit
This commit is contained in:
183
.agents/skills/better-auth-best-practices/SKILL.md
Normal file
183
.agents/skills/better-auth-best-practices/SKILL.md
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
---
|
||||||
|
name: better-auth-best-practices
|
||||||
|
description: Configure Better Auth server and client, set up database adapters, manage sessions, add plugins, and handle environment variables. Use when users mention Better Auth, betterauth, auth.ts, or need to set up TypeScript authentication with email/password, OAuth, or plugin configuration.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Better Auth Integration Guide
|
||||||
|
|
||||||
|
**Always consult [better-auth.com/docs](https://better-auth.com/docs) for code examples and latest API.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup Workflow
|
||||||
|
|
||||||
|
1. Install: `npm install better-auth`
|
||||||
|
2. Set env vars: `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL`
|
||||||
|
3. Create `auth.ts` with database + config
|
||||||
|
4. Create route handler for your framework
|
||||||
|
5. Run `npx @better-auth/cli@latest migrate`
|
||||||
|
6. Verify: call `GET /api/auth/ok` — should return `{ status: "ok" }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
- `BETTER_AUTH_SECRET` - Encryption secret (min 32 chars). Generate: `openssl rand -base64 32`
|
||||||
|
- `BETTER_AUTH_URL` - Base URL (e.g., `https://example.com`)
|
||||||
|
|
||||||
|
Only define `baseURL`/`secret` in config if env vars are NOT set.
|
||||||
|
|
||||||
|
### File Location
|
||||||
|
|
||||||
|
CLI looks for `auth.ts` in: `./`, `./lib`, `./utils`, or under `./src`. Use `--config` for custom path.
|
||||||
|
|
||||||
|
### CLI Commands
|
||||||
|
|
||||||
|
- `npx @better-auth/cli@latest migrate` - Apply schema (built-in adapter)
|
||||||
|
- `npx @better-auth/cli@latest generate` - Generate schema for Prisma/Drizzle
|
||||||
|
- `npx @better-auth/cli mcp --cursor` - Add MCP to AI tools
|
||||||
|
|
||||||
|
**Re-run after adding/changing plugins.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Config Options
|
||||||
|
|
||||||
|
| Option | Notes |
|
||||||
|
| ------------------ | ---------------------------------------------- |
|
||||||
|
| `appName` | Optional display name |
|
||||||
|
| `baseURL` | Only if `BETTER_AUTH_URL` not set |
|
||||||
|
| `basePath` | Default `/api/auth`. Set `/` for root. |
|
||||||
|
| `secret` | Only if `BETTER_AUTH_SECRET` not set |
|
||||||
|
| `database` | Required for most features. See adapters docs. |
|
||||||
|
| `secondaryStorage` | Redis/KV for sessions & rate limits |
|
||||||
|
| `emailAndPassword` | `{ enabled: true }` to activate |
|
||||||
|
| `socialProviders` | `{ google: { clientId, clientSecret }, ... }` |
|
||||||
|
| `plugins` | Array of plugins |
|
||||||
|
| `trustedOrigins` | CSRF whitelist |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
**Direct connections:** Pass `pg.Pool`, `mysql2` pool, `better-sqlite3`, or `bun:sqlite` instance.
|
||||||
|
|
||||||
|
**ORM adapters:** Import from `better-auth/adapters/drizzle`, `better-auth/adapters/prisma`, `better-auth/adapters/mongodb`.
|
||||||
|
|
||||||
|
**Critical:** Better Auth uses adapter model names, NOT underlying table names. If Prisma model is `User` mapping to table `users`, use `modelName: "user"` (Prisma reference), not `"users"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session Management
|
||||||
|
|
||||||
|
**Storage priority:**
|
||||||
|
|
||||||
|
1. If `secondaryStorage` defined → sessions go there (not DB)
|
||||||
|
2. Set `session.storeSessionInDatabase: true` to also persist to DB
|
||||||
|
3. No database + `cookieCache` → fully stateless mode
|
||||||
|
|
||||||
|
**Cookie cache strategies:**
|
||||||
|
|
||||||
|
- `compact` (default) - Base64url + HMAC. Smallest.
|
||||||
|
- `jwt` - Standard JWT. Readable but signed.
|
||||||
|
- `jwe` - Encrypted. Maximum security.
|
||||||
|
|
||||||
|
**Key options:** `session.expiresIn` (default 7 days), `session.updateAge` (refresh interval), `session.cookieCache.maxAge`, `session.cookieCache.version` (change to invalidate all sessions).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User & Account Config
|
||||||
|
|
||||||
|
**User:** `user.modelName`, `user.fields` (column mapping), `user.additionalFields`, `user.changeEmail.enabled` (disabled by default), `user.deleteUser.enabled` (disabled by default).
|
||||||
|
|
||||||
|
**Account:** `account.modelName`, `account.accountLinking.enabled`, `account.storeAccountCookie` (for stateless OAuth).
|
||||||
|
|
||||||
|
**Required for registration:** `email` and `name` fields.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Email Flows
|
||||||
|
|
||||||
|
- `emailVerification.sendVerificationEmail` - Must be defined for verification to work
|
||||||
|
- `emailVerification.sendOnSignUp` / `sendOnSignIn` - Auto-send triggers
|
||||||
|
- `emailAndPassword.sendResetPassword` - Password reset email handler
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
**In `advanced`:**
|
||||||
|
|
||||||
|
- `useSecureCookies` - Force HTTPS cookies
|
||||||
|
- `disableCSRFCheck` - ⚠️ Security risk
|
||||||
|
- `disableOriginCheck` - ⚠️ Security risk
|
||||||
|
- `crossSubDomainCookies.enabled` - Share cookies across subdomains
|
||||||
|
- `ipAddress.ipAddressHeaders` - Custom IP headers for proxies
|
||||||
|
- `database.generateId` - Custom ID generation or `"serial"`/`"uuid"`/`false`
|
||||||
|
|
||||||
|
**Rate limiting:** `rateLimit.enabled`, `rateLimit.window`, `rateLimit.max`, `rateLimit.storage` ("memory" | "database" | "secondary-storage").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
|
||||||
|
**Endpoint hooks:** `hooks.before` / `hooks.after` - Array of `{ matcher, handler }`. Use `createAuthMiddleware`. Access `ctx.path`, `ctx.context.returned` (after), `ctx.context.session`.
|
||||||
|
|
||||||
|
**Database hooks:** `databaseHooks.user.create.before/after`, same for `session`, `account`. Useful for adding default values or post-creation actions.
|
||||||
|
|
||||||
|
**Hook context (`ctx.context`):** `session`, `secret`, `authCookies`, `password.hash()`/`verify()`, `adapter`, `internalAdapter`, `generateId()`, `tables`, `baseURL`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plugins
|
||||||
|
|
||||||
|
**Import from dedicated paths for tree-shaking:**
|
||||||
|
|
||||||
|
```
|
||||||
|
import { twoFactor } from "better-auth/plugins/two-factor"
|
||||||
|
```
|
||||||
|
|
||||||
|
NOT `from "better-auth/plugins"`.
|
||||||
|
|
||||||
|
**Popular plugins:** `twoFactor`, `organization`, `passkey`, `magicLink`, `emailOtp`, `username`, `phoneNumber`, `admin`, `apiKey`, `bearer`, `jwt`, `multiSession`, `sso`, `oauthProvider`, `oidcProvider`, `openAPI`, `genericOAuth`.
|
||||||
|
|
||||||
|
Client plugins go in `createAuthClient({ plugins: [...] })`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client
|
||||||
|
|
||||||
|
Import from: `better-auth/client` (vanilla), `better-auth/react`, `better-auth/vue`, `better-auth/svelte`, `better-auth/solid`.
|
||||||
|
|
||||||
|
Key methods: `signUp.email()`, `signIn.email()`, `signIn.social()`, `signOut()`, `useSession()`, `getSession()`, `revokeSession()`, `revokeSessions()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Type Safety
|
||||||
|
|
||||||
|
Infer types: `typeof auth.$Infer.Session`, `typeof auth.$Infer.Session.user`.
|
||||||
|
|
||||||
|
For separate client/server projects: `createAuthClient<typeof auth>()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Gotchas
|
||||||
|
|
||||||
|
1. **Model vs table name** - Config uses ORM model name, not DB table name
|
||||||
|
2. **Plugin schema** - Re-run CLI after adding plugins
|
||||||
|
3. **Secondary storage** - Sessions go there by default, not DB
|
||||||
|
4. **Cookie cache** - Custom session fields NOT cached, always re-fetched
|
||||||
|
5. **Stateless mode** - No DB = session in cookie only, logout on cache expiry
|
||||||
|
6. **Change email flow** - Sends to current email first, then new email
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Docs](https://better-auth.com/docs)
|
||||||
|
- [Options Reference](https://better-auth.com/docs/reference/options)
|
||||||
|
- [LLMs.txt](https://better-auth.com/llms.txt)
|
||||||
|
- [GitHub](https://github.com/better-auth/better-auth)
|
||||||
|
- [Init Options Source](https://github.com/better-auth/better-auth/blob/main/packages/core/src/types/init-options.ts)
|
||||||
307
.agents/skills/building-native-ui/SKILL.md
Normal file
307
.agents/skills/building-native-ui/SKILL.md
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
---
|
||||||
|
name: building-native-ui
|
||||||
|
description: Complete guide for building beautiful apps with Expo Router. Covers fundamentals, styling, components, navigation, animations, patterns, and native tabs.
|
||||||
|
version: 1.0.1
|
||||||
|
license: MIT
|
||||||
|
---
|
||||||
|
|
||||||
|
# Expo UI Guidelines
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
Consult these resources as needed:
|
||||||
|
|
||||||
|
```
|
||||||
|
references/
|
||||||
|
animations.md Reanimated: entering, exiting, layout, scroll-driven, gestures
|
||||||
|
controls.md Native iOS: Switch, Slider, SegmentedControl, DateTimePicker, Picker
|
||||||
|
form-sheet.md Form sheets in expo-router: configuration, footers and background interaction.
|
||||||
|
gradients.md CSS gradients via experimental_backgroundImage (New Arch only)
|
||||||
|
icons.md SF Symbols via expo-image (sf: source), names, animations, weights
|
||||||
|
media.md Camera, audio, video, and file saving
|
||||||
|
route-structure.md Route conventions, dynamic routes, groups, folder organization
|
||||||
|
search.md Search bar with headers, useSearch hook, filtering patterns
|
||||||
|
storage.md SQLite, AsyncStorage, SecureStore
|
||||||
|
tabs.md NativeTabs, migration from JS tabs, iOS 26 features
|
||||||
|
toolbar-and-headers.md Stack headers and toolbar buttons, menus, search (iOS only)
|
||||||
|
visual-effects.md Blur (expo-blur) and liquid glass (expo-glass-effect)
|
||||||
|
webgpu-three.md 3D graphics, games, GPU visualizations with WebGPU and Three.js
|
||||||
|
zoom-transitions.md Apple Zoom: fluid zoom transitions with Link.AppleZoom (iOS 18+)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running the App
|
||||||
|
|
||||||
|
**CRITICAL: Always try Expo Go first before creating custom builds.**
|
||||||
|
|
||||||
|
Most Expo apps work in Expo Go without any custom native code. Before running `npx expo run:ios` or `npx expo run:android`:
|
||||||
|
|
||||||
|
1. **Start with Expo Go**: Run `npx expo start` and scan the QR code with Expo Go
|
||||||
|
2. **Check if features work**: Test your app thoroughly in Expo Go
|
||||||
|
3. **Only create custom builds when required** - see below
|
||||||
|
|
||||||
|
### When Custom Builds Are Required
|
||||||
|
|
||||||
|
You need `npx expo run:ios/android` or `eas build` ONLY when using:
|
||||||
|
|
||||||
|
- **Local Expo modules** (custom native code in `modules/`)
|
||||||
|
- **Apple targets** (widgets, app clips, extensions via `@bacons/apple-targets`)
|
||||||
|
- **Third-party native modules** not included in Expo Go
|
||||||
|
- **Custom native configuration** that can't be expressed in `app.json`
|
||||||
|
|
||||||
|
### When Expo Go Works
|
||||||
|
|
||||||
|
Expo Go supports a huge range of features out of the box:
|
||||||
|
|
||||||
|
- All `expo-*` packages (camera, location, notifications, etc.)
|
||||||
|
- Expo Router navigation
|
||||||
|
- Most UI libraries (reanimated, gesture handler, etc.)
|
||||||
|
- Push notifications, deep links, and more
|
||||||
|
|
||||||
|
**If you're unsure, try Expo Go first.** Creating custom builds adds complexity, slower iteration, and requires Xcode/Android Studio setup.
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- Be cautious of unterminated strings. Ensure nested backticks are escaped; never forget to escape quotes correctly.
|
||||||
|
- Always use import statements at the top of the file.
|
||||||
|
- Always use kebab-case for file names, e.g. `comment-card.tsx`
|
||||||
|
- Always remove old route files when moving or restructuring navigation
|
||||||
|
- Never use special characters in file names
|
||||||
|
- Configure tsconfig.json with path aliases, and prefer aliases over relative imports for refactors.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
See `./references/route-structure.md` for detailed route conventions.
|
||||||
|
|
||||||
|
- Routes belong in the `app` directory.
|
||||||
|
- Never co-locate components, types, or utilities in the app directory. This is an anti-pattern.
|
||||||
|
- Ensure the app always has a route that matches "/", it may be inside a group route.
|
||||||
|
|
||||||
|
## Library Preferences
|
||||||
|
|
||||||
|
- Never use modules removed from React Native such as Picker, WebView, SafeAreaView, or AsyncStorage
|
||||||
|
- Never use legacy expo-permissions
|
||||||
|
- `expo-audio` not `expo-av`
|
||||||
|
- `expo-video` not `expo-av`
|
||||||
|
- `expo-image` with `source="sf:name"` for SF Symbols, not `expo-symbols` or `@expo/vector-icons`
|
||||||
|
- `react-native-safe-area-context` not react-native SafeAreaView
|
||||||
|
- `process.env.EXPO_OS` not `Platform.OS`
|
||||||
|
- `React.use` not `React.useContext`
|
||||||
|
- `expo-image` Image component instead of intrinsic element `img`
|
||||||
|
- `expo-glass-effect` for liquid glass backdrops
|
||||||
|
|
||||||
|
## Responsiveness
|
||||||
|
|
||||||
|
- Always wrap root component in a scroll view for responsiveness
|
||||||
|
- Use `<ScrollView contentInsetAdjustmentBehavior="automatic" />` instead of `<SafeAreaView>` for smarter safe area insets
|
||||||
|
- `contentInsetAdjustmentBehavior="automatic"` should be applied to FlatList and SectionList as well
|
||||||
|
- Use flexbox instead of Dimensions API
|
||||||
|
- ALWAYS prefer `useWindowDimensions` over `Dimensions.get()` to measure screen size
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
- Use expo-haptics conditionally on iOS to make more delightful experiences
|
||||||
|
- Use views with built-in haptics like `<Switch />` from React Native and `@react-native-community/datetimepicker`
|
||||||
|
- When a route belongs to a Stack, its first child should almost always be a ScrollView with `contentInsetAdjustmentBehavior="automatic"` set
|
||||||
|
- When adding a `ScrollView` to the page it should almost always be the first component inside the route component
|
||||||
|
- Prefer `headerSearchBarOptions` in Stack.Screen options to add a search bar
|
||||||
|
- Use the `<Text selectable />` prop on text containing data that could be copied
|
||||||
|
- Consider formatting large numbers like 1.4M or 38k
|
||||||
|
- Never use intrinsic elements like 'img' or 'div' unless in a webview or Expo DOM component
|
||||||
|
|
||||||
|
# Styling
|
||||||
|
|
||||||
|
Follow Apple Human Interface Guidelines.
|
||||||
|
|
||||||
|
## General Styling Rules
|
||||||
|
|
||||||
|
- Prefer flex gap over margin and padding styles
|
||||||
|
- Prefer padding over margin where possible
|
||||||
|
- Always account for safe area, either with stack headers, tabs, or ScrollView/FlatList `contentInsetAdjustmentBehavior="automatic"`
|
||||||
|
- Ensure both top and bottom safe area insets are accounted for
|
||||||
|
- Inline styles not StyleSheet.create unless reusing styles is faster
|
||||||
|
- Add entering and exiting animations for state changes
|
||||||
|
- Use `{ borderCurve: 'continuous' }` for rounded corners unless creating a capsule shape
|
||||||
|
- ALWAYS use a navigation stack title instead of a custom text element on the page
|
||||||
|
- When padding a ScrollView, use `contentContainerStyle` padding and gap instead of padding on the ScrollView itself (reduces clipping)
|
||||||
|
- CSS and Tailwind are not supported - use inline styles
|
||||||
|
|
||||||
|
## Text Styling
|
||||||
|
|
||||||
|
- Add the `selectable` prop to every `<Text/>` element displaying important data or error messages
|
||||||
|
- Counters should use `{ fontVariant: 'tabular-nums' }` for alignment
|
||||||
|
|
||||||
|
## Shadows
|
||||||
|
|
||||||
|
Use CSS `boxShadow` style prop. NEVER use legacy React Native shadow or elevation styles.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<View style={{ boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)" }} />
|
||||||
|
```
|
||||||
|
|
||||||
|
'inset' shadows are supported.
|
||||||
|
|
||||||
|
# Navigation
|
||||||
|
|
||||||
|
## Link
|
||||||
|
|
||||||
|
Use `<Link href="/path" />` from 'expo-router' for navigation between routes.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Link } from 'expo-router';
|
||||||
|
|
||||||
|
// Basic link
|
||||||
|
<Link href="/path" />
|
||||||
|
|
||||||
|
// Wrapping custom components
|
||||||
|
<Link href="/path" asChild>
|
||||||
|
<Pressable>...</Pressable>
|
||||||
|
</Link>
|
||||||
|
```
|
||||||
|
|
||||||
|
Whenever possible, include a `<Link.Preview>` to follow iOS conventions. Add context menus and previews frequently to enhance navigation.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- ALWAYS use `_layout.tsx` files to define stacks
|
||||||
|
- Use Stack from 'expo-router/stack' for native navigation stacks
|
||||||
|
|
||||||
|
### Page Title
|
||||||
|
|
||||||
|
Set the page title in Stack.Screen options:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Stack.Screen options={{ title: "Home" }} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context Menus
|
||||||
|
|
||||||
|
Add long press context menus to Link components:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Link } from "expo-router";
|
||||||
|
|
||||||
|
<Link href="/settings" asChild>
|
||||||
|
<Link.Trigger>
|
||||||
|
<Pressable>
|
||||||
|
<Card />
|
||||||
|
</Pressable>
|
||||||
|
</Link.Trigger>
|
||||||
|
<Link.Menu>
|
||||||
|
<Link.MenuAction title="Share" icon="square.and.arrow.up" onPress={handleSharePress} />
|
||||||
|
<Link.MenuAction title="Block" icon="nosign" destructive onPress={handleBlockPress} />
|
||||||
|
<Link.Menu title="More" icon="ellipsis">
|
||||||
|
<Link.MenuAction title="Copy" icon="doc.on.doc" onPress={() => {}} />
|
||||||
|
<Link.MenuAction title="Delete" icon="trash" destructive onPress={() => {}} />
|
||||||
|
</Link.Menu>
|
||||||
|
</Link.Menu>
|
||||||
|
</Link>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Link Previews
|
||||||
|
|
||||||
|
Use link previews frequently to enhance navigation:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Link href="/settings">
|
||||||
|
<Link.Trigger>
|
||||||
|
<Pressable>
|
||||||
|
<Card />
|
||||||
|
</Pressable>
|
||||||
|
</Link.Trigger>
|
||||||
|
<Link.Preview />
|
||||||
|
</Link>
|
||||||
|
```
|
||||||
|
|
||||||
|
Link preview can be used with context menus.
|
||||||
|
|
||||||
|
## Modal
|
||||||
|
|
||||||
|
Present a screen as a modal:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Stack.Screen name="modal" options={{ presentation: "modal" }} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer this to building a custom modal component.
|
||||||
|
|
||||||
|
## Sheet
|
||||||
|
|
||||||
|
Present a screen as a dynamic form sheet:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Stack.Screen
|
||||||
|
name="sheet"
|
||||||
|
options={{
|
||||||
|
presentation: "formSheet",
|
||||||
|
sheetGrabberVisible: true,
|
||||||
|
sheetAllowedDetents: [0.5, 1.0],
|
||||||
|
contentStyle: { backgroundColor: "transparent" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Using `contentStyle: { backgroundColor: "transparent" }` makes the background liquid glass on iOS 26+.
|
||||||
|
|
||||||
|
## Common route structure
|
||||||
|
|
||||||
|
A standard app layout with tabs and stacks inside each tab:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
_layout.tsx — <NativeTabs />
|
||||||
|
(index,search)/
|
||||||
|
_layout.tsx — <Stack />
|
||||||
|
index.tsx — Main list
|
||||||
|
search.tsx — Search view
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/_layout.tsx
|
||||||
|
import { NativeTabs, Icon, Label } from "expo-router/unstable-native-tabs";
|
||||||
|
import { Theme } from "../components/theme";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Theme>
|
||||||
|
<NativeTabs>
|
||||||
|
<NativeTabs.Trigger name="(index)">
|
||||||
|
<Icon sf="list.dash" />
|
||||||
|
<Label>Items</Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
<NativeTabs.Trigger name="(search)" role="search" />
|
||||||
|
</NativeTabs>
|
||||||
|
</Theme>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a shared group route so both tabs can push common screens:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/(index,search)/_layout.tsx
|
||||||
|
import { Stack } from "expo-router/stack";
|
||||||
|
import { PlatformColor } from "react-native";
|
||||||
|
|
||||||
|
export default function Layout({ segment }) {
|
||||||
|
const screen = segment.match(/\((.*)\)/)?.[1]!;
|
||||||
|
const titles: Record<string, string> = { index: "Items", search: "Search" };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerTransparent: true,
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLargeTitleShadowVisible: false,
|
||||||
|
headerLargeStyle: { backgroundColor: "transparent" },
|
||||||
|
headerTitleStyle: { color: PlatformColor("label") },
|
||||||
|
headerLargeTitle: true,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerBackButtonDisplayMode: "minimal",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen name={screen} options={{ title: titles[screen] }} />
|
||||||
|
<Stack.Screen name="i/[id]" options={{ headerLargeTitle: false }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
189
.agents/skills/building-native-ui/references/animations.md
Normal file
189
.agents/skills/building-native-ui/references/animations.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# Animations
|
||||||
|
|
||||||
|
Use Reanimated v4. Avoid React Native's built-in Animated API.
|
||||||
|
|
||||||
|
## Entering and Exiting Animations
|
||||||
|
|
||||||
|
Use Animated.View with entering and exiting animations. Layout animations can animate state changes.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return <Animated.View entering={FadeIn} exiting={FadeOut} layout={LinearTransition} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## On-Scroll Animations
|
||||||
|
|
||||||
|
Create high-performance scroll animations using Reanimated's hooks:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Animated, {
|
||||||
|
useAnimatedRef,
|
||||||
|
useScrollViewOffset,
|
||||||
|
useAnimatedStyle,
|
||||||
|
interpolate,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
|
||||||
|
function Page() {
|
||||||
|
const ref = useAnimatedRef();
|
||||||
|
const scroll = useScrollViewOffset(ref);
|
||||||
|
|
||||||
|
const style = useAnimatedStyle(() => ({
|
||||||
|
opacity: interpolate(scroll.value, [0, 30], [0, 1], "clamp"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.ScrollView ref={ref}>
|
||||||
|
<Animated.View style={style} />
|
||||||
|
</Animated.ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Animation Presets
|
||||||
|
|
||||||
|
### Entering Animations
|
||||||
|
|
||||||
|
- `FadeIn`, `FadeInUp`, `FadeInDown`, `FadeInLeft`, `FadeInRight`
|
||||||
|
- `SlideInUp`, `SlideInDown`, `SlideInLeft`, `SlideInRight`
|
||||||
|
- `ZoomIn`, `ZoomInUp`, `ZoomInDown`
|
||||||
|
- `BounceIn`, `BounceInUp`, `BounceInDown`
|
||||||
|
|
||||||
|
### Exiting Animations
|
||||||
|
|
||||||
|
- `FadeOut`, `FadeOutUp`, `FadeOutDown`, `FadeOutLeft`, `FadeOutRight`
|
||||||
|
- `SlideOutUp`, `SlideOutDown`, `SlideOutLeft`, `SlideOutRight`
|
||||||
|
- `ZoomOut`, `ZoomOutUp`, `ZoomOutDown`
|
||||||
|
- `BounceOut`, `BounceOutUp`, `BounceOutDown`
|
||||||
|
|
||||||
|
### Layout Animations
|
||||||
|
|
||||||
|
- `LinearTransition` — Smooth linear interpolation
|
||||||
|
- `SequencedTransition` — Sequenced property changes
|
||||||
|
- `FadingTransition` — Fade between states
|
||||||
|
|
||||||
|
## Customizing Animations
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Animated.View entering={FadeInDown.duration(500).delay(200)} exiting={FadeOut.duration(300)} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modifiers
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Duration in milliseconds
|
||||||
|
FadeIn.duration(300);
|
||||||
|
|
||||||
|
// Delay before starting
|
||||||
|
FadeIn.delay(100);
|
||||||
|
|
||||||
|
// Spring physics
|
||||||
|
FadeIn.springify();
|
||||||
|
FadeIn.springify().damping(15).stiffness(100);
|
||||||
|
|
||||||
|
// Easing curves
|
||||||
|
FadeIn.easing(Easing.bezier(0.25, 0.1, 0.25, 1));
|
||||||
|
|
||||||
|
// Chaining
|
||||||
|
FadeInDown.duration(400).delay(200).springify();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shared Value Animations
|
||||||
|
|
||||||
|
For imperative control over animations:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useSharedValue, withSpring, withTiming } from "react-native-reanimated";
|
||||||
|
|
||||||
|
const offset = useSharedValue(0);
|
||||||
|
|
||||||
|
// Spring animation
|
||||||
|
offset.value = withSpring(100);
|
||||||
|
|
||||||
|
// Timing animation
|
||||||
|
offset.value = withTiming(100, { duration: 300 });
|
||||||
|
|
||||||
|
// Use in styles
|
||||||
|
const style = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateX: offset.value }],
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gesture Animations
|
||||||
|
|
||||||
|
Combine with React Native Gesture Handler:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
|
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
|
||||||
|
|
||||||
|
function DraggableBox() {
|
||||||
|
const translateX = useSharedValue(0);
|
||||||
|
const translateY = useSharedValue(0);
|
||||||
|
|
||||||
|
const gesture = Gesture.Pan()
|
||||||
|
.onUpdate((e) => {
|
||||||
|
translateX.value = e.translationX;
|
||||||
|
translateY.value = e.translationY;
|
||||||
|
})
|
||||||
|
.onEnd(() => {
|
||||||
|
translateX.value = withSpring(0);
|
||||||
|
translateY.value = withSpring(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateX: translateX.value }, { translateY: translateY.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GestureDetector gesture={gesture}>
|
||||||
|
<Animated.View style={[styles.box, style]} />
|
||||||
|
</GestureDetector>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Keyboard Animations
|
||||||
|
|
||||||
|
Animate with keyboard height changes:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Animated, { useAnimatedKeyboard, useAnimatedStyle } from "react-native-reanimated";
|
||||||
|
|
||||||
|
function KeyboardAwareView() {
|
||||||
|
const keyboard = useAnimatedKeyboard();
|
||||||
|
|
||||||
|
const style = useAnimatedStyle(() => ({
|
||||||
|
paddingBottom: keyboard.height.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return <Animated.View style={style}>{/* content */}</Animated.View>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Staggered List Animations
|
||||||
|
|
||||||
|
Animate list items with delays:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{
|
||||||
|
items.map((item, index) => (
|
||||||
|
<Animated.View key={item.id} entering={FadeInUp.delay(index * 50)} exiting={FadeOutUp}>
|
||||||
|
<ListItem item={item} />
|
||||||
|
</Animated.View>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Add entering and exiting animations for state changes
|
||||||
|
- Use layout animations when items are added/removed from lists
|
||||||
|
- Use `useAnimatedStyle` for scroll-driven animations
|
||||||
|
- Prefer `interpolate` with "clamp" for bounded values
|
||||||
|
- You can't pass PlatformColors to reanimated views or styles; use static colors instead
|
||||||
|
- Keep animations under 300ms for responsive feel
|
||||||
|
- Use spring animations for natural movement
|
||||||
|
- Avoid animating layout properties (width, height) when possible — prefer transforms
|
||||||
245
.agents/skills/building-native-ui/references/controls.md
Normal file
245
.agents/skills/building-native-ui/references/controls.md
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
# Native Controls
|
||||||
|
|
||||||
|
Native iOS controls provide built-in haptics, accessibility, and platform-appropriate styling.
|
||||||
|
|
||||||
|
## Switch
|
||||||
|
|
||||||
|
Use for binary on/off settings. Has built-in haptics.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Switch } from "react-native";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
|
||||||
|
<Switch value={enabled} onValueChange={setEnabled} />;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Customization
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Switch
|
||||||
|
value={enabled}
|
||||||
|
onValueChange={setEnabled}
|
||||||
|
trackColor={{ false: "#767577", true: "#81b0ff" }}
|
||||||
|
thumbColor={enabled ? "#f5dd4b" : "#f4f3f4"}
|
||||||
|
ios_backgroundColor="#3e3e3e"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Segmented Control
|
||||||
|
|
||||||
|
Use for non-navigational tabs or mode selection. Avoid changing default colors.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import SegmentedControl from "@react-native-segmented-control/segmented-control";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
|
||||||
|
<SegmentedControl
|
||||||
|
values={["All", "Active", "Done"]}
|
||||||
|
selectedIndex={index}
|
||||||
|
onChange={({ nativeEvent }) => setIndex(nativeEvent.selectedSegmentIndex)}
|
||||||
|
/>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
- Maximum 4 options — use a picker for more
|
||||||
|
- Keep labels short (1-2 words)
|
||||||
|
- Avoid custom colors — native styling adapts to dark mode
|
||||||
|
|
||||||
|
### With Icons (iOS 14+)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SegmentedControl
|
||||||
|
values={[
|
||||||
|
{ label: "List", icon: "list.bullet" },
|
||||||
|
{ label: "Grid", icon: "square.grid.2x2" },
|
||||||
|
]}
|
||||||
|
selectedIndex={index}
|
||||||
|
onChange={({ nativeEvent }) => setIndex(nativeEvent.selectedSegmentIndex)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Slider
|
||||||
|
|
||||||
|
Continuous value selection.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Slider from "@react-native-community/slider";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const [value, setValue] = useState(0.5);
|
||||||
|
|
||||||
|
<Slider value={value} onValueChange={setValue} minimumValue={0} maximumValue={1} />;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Customization
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Slider
|
||||||
|
value={value}
|
||||||
|
onValueChange={setValue}
|
||||||
|
minimumValue={0}
|
||||||
|
maximumValue={100}
|
||||||
|
step={1}
|
||||||
|
minimumTrackTintColor="#007AFF"
|
||||||
|
maximumTrackTintColor="#E5E5EA"
|
||||||
|
thumbTintColor="#007AFF"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Discrete Steps
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Slider value={value} onValueChange={setValue} minimumValue={0} maximumValue={10} step={1} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Date/Time Picker
|
||||||
|
|
||||||
|
Compact pickers with popovers. Has built-in haptics.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import DateTimePicker from "@react-native-community/datetimepicker";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const [date, setDate] = useState(new Date());
|
||||||
|
|
||||||
|
<DateTimePicker
|
||||||
|
value={date}
|
||||||
|
onChange={(event, selectedDate) => {
|
||||||
|
if (selectedDate) setDate(selectedDate);
|
||||||
|
}}
|
||||||
|
mode="datetime"
|
||||||
|
/>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modes
|
||||||
|
|
||||||
|
- `date` — Date only
|
||||||
|
- `time` — Time only
|
||||||
|
- `datetime` — Date and time
|
||||||
|
|
||||||
|
### Display Styles
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Compact inline (default)
|
||||||
|
<DateTimePicker value={date} mode="date" />
|
||||||
|
|
||||||
|
// Spinner wheel
|
||||||
|
<DateTimePicker
|
||||||
|
value={date}
|
||||||
|
mode="date"
|
||||||
|
display="spinner"
|
||||||
|
style={{ width: 200, height: 150 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Full calendar
|
||||||
|
<DateTimePicker value={date} mode="date" display="inline" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Time Intervals
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DateTimePicker value={date} mode="time" minuteInterval={15} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Min/Max Dates
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DateTimePicker
|
||||||
|
value={date}
|
||||||
|
mode="date"
|
||||||
|
minimumDate={new Date(2020, 0, 1)}
|
||||||
|
maximumDate={new Date(2030, 11, 31)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stepper
|
||||||
|
|
||||||
|
Increment/decrement numeric values.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Stepper } from "react-native";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
|
||||||
|
<Stepper value={count} onValueChange={setCount} minimumValue={0} maximumValue={10} />;
|
||||||
|
```
|
||||||
|
|
||||||
|
## TextInput
|
||||||
|
|
||||||
|
Native text input with various keyboard types.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { TextInput } from "react-native";
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
placeholder="Enter text..."
|
||||||
|
placeholderTextColor="#999"
|
||||||
|
style={{
|
||||||
|
padding: 12,
|
||||||
|
fontSize: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: "#f0f0f0",
|
||||||
|
}}
|
||||||
|
/>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keyboard Types
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Email
|
||||||
|
<TextInput keyboardType="email-address" autoCapitalize="none" />
|
||||||
|
|
||||||
|
// Phone
|
||||||
|
<TextInput keyboardType="phone-pad" />
|
||||||
|
|
||||||
|
// Number
|
||||||
|
<TextInput keyboardType="numeric" />
|
||||||
|
|
||||||
|
// Password
|
||||||
|
<TextInput secureTextEntry />
|
||||||
|
|
||||||
|
// Search
|
||||||
|
<TextInput
|
||||||
|
returnKeyType="search"
|
||||||
|
enablesReturnKeyAutomatically
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiline
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<TextInput multiline numberOfLines={4} textAlignVertical="top" style={{ minHeight: 100 }} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Picker (Wheel)
|
||||||
|
|
||||||
|
For selection from many options (5+ items).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Picker } from "@react-native-picker/picker";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState("js");
|
||||||
|
|
||||||
|
<Picker selectedValue={selected} onValueChange={setSelected}>
|
||||||
|
<Picker.Item label="JavaScript" value="js" />
|
||||||
|
<Picker.Item label="TypeScript" value="ts" />
|
||||||
|
<Picker.Item label="Python" value="py" />
|
||||||
|
<Picker.Item label="Go" value="go" />
|
||||||
|
</Picker>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- **Haptics**: Switch and DateTimePicker have built-in haptics — don't add extra
|
||||||
|
- **Accessibility**: Native controls have proper accessibility labels by default
|
||||||
|
- **Dark Mode**: Avoid custom colors — native styling adapts automatically
|
||||||
|
- **Spacing**: Use consistent padding around controls (12-16pt)
|
||||||
|
- **Labels**: Place labels above or to the left of controls
|
||||||
|
- **Grouping**: Group related controls in sections with headers
|
||||||
251
.agents/skills/building-native-ui/references/form-sheet.md
Normal file
251
.agents/skills/building-native-ui/references/form-sheet.md
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
# Form Sheets in Expo Router
|
||||||
|
|
||||||
|
This skill covers implementing form sheets with footers using Expo Router's Stack navigator and react-native-screens.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Form sheets are modal presentations that appear as a card sliding up from the bottom of the screen. They're ideal for:
|
||||||
|
|
||||||
|
- Quick actions and confirmations
|
||||||
|
- Settings panels
|
||||||
|
- Login/signup flows
|
||||||
|
- Action sheets with custom content
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
|
||||||
|
- Expo Router Stack navigator
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
### Form Sheet with Footer
|
||||||
|
|
||||||
|
Configure the Stack.Screen with transparent backgrounds and sheet presentation:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/_layout.tsx
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen name="index" />
|
||||||
|
<Stack.Screen
|
||||||
|
name="about"
|
||||||
|
options={{
|
||||||
|
presentation: "formSheet",
|
||||||
|
sheetAllowedDetents: [0.25],
|
||||||
|
headerTransparent: true,
|
||||||
|
contentStyle: { backgroundColor: "transparent" },
|
||||||
|
sheetGrabberVisible: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Header style={{ backgroundColor: "transparent" }}></Stack.Header>
|
||||||
|
</Stack.Screen>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Form Sheet Screen Content
|
||||||
|
|
||||||
|
> Requires Expo SDK 55 or later.
|
||||||
|
|
||||||
|
Use `flex: 1` to allow the content to fill available space, enabling footer positioning:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/about.tsx
|
||||||
|
import { View, Text, StyleSheet } from "react-native";
|
||||||
|
|
||||||
|
export default function AboutSheet() {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Main content */}
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text>Sheet Content</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Footer - stays at bottom */}
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Text>Footer Content</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Formsheet with interactive content below
|
||||||
|
|
||||||
|
Use `sheetLargestUndimmedDetentIndex` (zero-indexed) to keep content behind the form sheet interactive — e.g. letting users pan a map beneath it. Setting it to `1` allows interaction at the first two detents but dims on the third.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/_layout.tsx
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="index" />
|
||||||
|
<Stack.Screen
|
||||||
|
name="info-sheet"
|
||||||
|
options={{
|
||||||
|
presentation: "formSheet",
|
||||||
|
sheetAllowedDetents: [0.2, 0.5, 1.0],
|
||||||
|
sheetLargestUndimmedDetentIndex: 1,
|
||||||
|
/* other options */
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Options
|
||||||
|
|
||||||
|
| Option | Type | Description |
|
||||||
|
| --------------------- | ---------- | ----------------------------------------------------------- |
|
||||||
|
| `presentation` | `string` | Set to `'formSheet'` for sheet presentation |
|
||||||
|
| `sheetGrabberVisible` | `boolean` | Shows the drag handle at the top of the sheet |
|
||||||
|
| `sheetAllowedDetents` | `number[]` | Array of detent heights (0-1 range, e.g., `[0.25]` for 25%) |
|
||||||
|
| `headerTransparent` | `boolean` | Makes header background transparent |
|
||||||
|
| `contentStyle` | `object` | Style object for the screen content container |
|
||||||
|
| `title` | `string` | Screen title (set to `''` for no title) |
|
||||||
|
|
||||||
|
## Common Detent Values
|
||||||
|
|
||||||
|
- `[0.25]` - Quarter sheet (compact actions)
|
||||||
|
- `[0.5]` - Half sheet (medium content)
|
||||||
|
- `[0.75]` - Three-quarter sheet (detailed forms)
|
||||||
|
- `[0.25, 0.5, 1]` - Multiple stops (expandable sheet)
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// _layout.tsx
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen name="index" options={{ title: "Home" }} />
|
||||||
|
<Stack.Screen
|
||||||
|
name="confirm"
|
||||||
|
options={{
|
||||||
|
contentStyle: { backgroundColor: "transparent" },
|
||||||
|
presentation: "formSheet",
|
||||||
|
title: "",
|
||||||
|
sheetGrabberVisible: true,
|
||||||
|
sheetAllowedDetents: [0.25],
|
||||||
|
headerTransparent: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Header style={{ backgroundColor: "transparent" }}>
|
||||||
|
<Stack.Header.Right />
|
||||||
|
</Stack.Header>
|
||||||
|
</Stack.Screen>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/confirm.tsx
|
||||||
|
import { View, Text, Pressable, StyleSheet } from "react-native";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
|
||||||
|
export default function ConfirmSheet() {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.title}>Confirm Action</Text>
|
||||||
|
<Text style={styles.description}>Are you sure you want to proceed?</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Pressable style={styles.cancelButton} onPress={() => router.back()}>
|
||||||
|
<Text style={styles.cancelText}>Cancel</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable style={styles.confirmButton} onPress={() => router.back()}>
|
||||||
|
<Text style={styles.confirmText}>Confirm</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 20,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "600",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#666",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
padding: 16,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 14,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: "#f0f0f0",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
cancelText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
confirmButton: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 14,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: "#007AFF",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
confirmText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "500",
|
||||||
|
color: "white",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Content not filling sheet
|
||||||
|
|
||||||
|
Make sure the root View uses `flex: 1`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<View style={{ flex: 1 }}>{/* content */}</View>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sheet background showing through
|
||||||
|
|
||||||
|
Set `contentStyle: { backgroundColor: 'transparent' }` in options and style your content container with the desired background color instead.
|
||||||
116
.agents/skills/building-native-ui/references/gradients.md
Normal file
116
.agents/skills/building-native-ui/references/gradients.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# CSS Gradients
|
||||||
|
|
||||||
|
> **New Architecture Only**: CSS gradients require React Native's New Architecture (Fabric). They are not available in the old architecture or Expo Go.
|
||||||
|
|
||||||
|
Use CSS gradients with the `experimental_backgroundImage` style property.
|
||||||
|
|
||||||
|
## Linear Gradients
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Top to bottom
|
||||||
|
<View style={{
|
||||||
|
experimental_backgroundImage: 'linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) 100%)'
|
||||||
|
}} />
|
||||||
|
|
||||||
|
// Left to right
|
||||||
|
<View style={{
|
||||||
|
experimental_backgroundImage: 'linear-gradient(to right, #ff0000 0%, #0000ff 100%)'
|
||||||
|
}} />
|
||||||
|
|
||||||
|
// Diagonal
|
||||||
|
<View style={{
|
||||||
|
experimental_backgroundImage: 'linear-gradient(45deg, #ff0000 0%, #00ff00 50%, #0000ff 100%)'
|
||||||
|
}} />
|
||||||
|
|
||||||
|
// Using degrees
|
||||||
|
<View style={{
|
||||||
|
experimental_backgroundImage: 'linear-gradient(135deg, transparent 0%, black 100%)'
|
||||||
|
}} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Radial Gradients
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Circle at center
|
||||||
|
<View style={{
|
||||||
|
experimental_backgroundImage: 'radial-gradient(circle at center, rgba(255, 0, 0, 1) 0%, rgba(0, 0, 255, 1) 100%)'
|
||||||
|
}} />
|
||||||
|
|
||||||
|
// Ellipse
|
||||||
|
<View style={{
|
||||||
|
experimental_backgroundImage: 'radial-gradient(ellipse at center, #fff 0%, #000 100%)'
|
||||||
|
}} />
|
||||||
|
|
||||||
|
// Positioned
|
||||||
|
<View style={{
|
||||||
|
experimental_backgroundImage: 'radial-gradient(circle at top left, #ff0000 0%, transparent 70%)'
|
||||||
|
}} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multiple Gradients
|
||||||
|
|
||||||
|
Stack multiple gradients by comma-separating them:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
experimental_backgroundImage: `
|
||||||
|
linear-gradient(to bottom, transparent 0%, black 100%),
|
||||||
|
radial-gradient(circle at top right, rgba(255, 0, 0, 0.5) 0%, transparent 50%)
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Overlay on Image
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<View style={{ position: "relative" }}>
|
||||||
|
<Image source={{ uri: "..." }} style={{ width: "100%", height: 200 }} />
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
experimental_backgroundImage:
|
||||||
|
"linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, transparent 50%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frosted Glass Effect
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
experimental_backgroundImage:
|
||||||
|
"linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%)",
|
||||||
|
backdropFilter: "blur(10px)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Button Gradient
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Pressable
|
||||||
|
style={{
|
||||||
|
experimental_backgroundImage: "linear-gradient(to bottom, #4CAF50 0%, #388E3C 100%)",
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: "white", textAlign: "center" }}>Submit</Text>
|
||||||
|
</Pressable>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- Do NOT use `expo-linear-gradient` — use CSS gradients instead
|
||||||
|
- Gradients are strings, not objects
|
||||||
|
- Use `rgba()` for transparency, or `transparent` keyword
|
||||||
|
- Color stops use percentages (0%, 50%, 100%)
|
||||||
|
- Direction keywords: `to top`, `to bottom`, `to left`, `to right`, `to top left`, etc.
|
||||||
|
- Degree values: `45deg`, `90deg`, `135deg`, etc.
|
||||||
218
.agents/skills/building-native-ui/references/icons.md
Normal file
218
.agents/skills/building-native-ui/references/icons.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# Icons (SF Symbols)
|
||||||
|
|
||||||
|
Use SF Symbols for native feel. Never use FontAwesome or Ionicons.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { SymbolView } from "expo-symbols";
|
||||||
|
import { PlatformColor } from "react-native";
|
||||||
|
|
||||||
|
<SymbolView
|
||||||
|
tintColor={PlatformColor("label")}
|
||||||
|
resizeMode="scaleAspectFit"
|
||||||
|
name="square.and.arrow.down"
|
||||||
|
style={{ width: 16, height: 16 }}
|
||||||
|
/>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Props
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SymbolView
|
||||||
|
name="star.fill" // SF Symbol name (required)
|
||||||
|
tintColor={PlatformColor("label")} // Icon color
|
||||||
|
size={24} // Shorthand for width/height
|
||||||
|
resizeMode="scaleAspectFit" // How to scale
|
||||||
|
weight="regular" // thin | ultraLight | light | regular | medium | semibold | bold | heavy | black
|
||||||
|
scale="medium" // small | medium | large
|
||||||
|
style={{ width: 16, height: 16 }} // Standard style props
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Icons
|
||||||
|
|
||||||
|
### Navigation & Actions
|
||||||
|
|
||||||
|
- `house.fill` - home
|
||||||
|
- `gear` - settings
|
||||||
|
- `magnifyingglass` - search
|
||||||
|
- `plus` - add
|
||||||
|
- `xmark` - close
|
||||||
|
- `chevron.left` - back
|
||||||
|
- `chevron.right` - forward
|
||||||
|
- `arrow.left` - back arrow
|
||||||
|
- `arrow.right` - forward arrow
|
||||||
|
|
||||||
|
### Media
|
||||||
|
|
||||||
|
- `play.fill` - play
|
||||||
|
- `pause.fill` - pause
|
||||||
|
- `stop.fill` - stop
|
||||||
|
- `backward.fill` - rewind
|
||||||
|
- `forward.fill` - fast forward
|
||||||
|
- `speaker.wave.2.fill` - volume
|
||||||
|
- `speaker.slash.fill` - mute
|
||||||
|
|
||||||
|
### Camera
|
||||||
|
|
||||||
|
- `camera` - camera
|
||||||
|
- `camera.fill` - camera filled
|
||||||
|
- `arrow.triangle.2.circlepath` - flip camera
|
||||||
|
- `photo` - gallery/photos
|
||||||
|
- `bolt` - flash
|
||||||
|
- `bolt.slash` - flash off
|
||||||
|
|
||||||
|
### Communication
|
||||||
|
|
||||||
|
- `message` - message
|
||||||
|
- `message.fill` - message filled
|
||||||
|
- `envelope` - email
|
||||||
|
- `envelope.fill` - email filled
|
||||||
|
- `phone` - phone
|
||||||
|
- `phone.fill` - phone filled
|
||||||
|
- `video` - video call
|
||||||
|
- `video.fill` - video call filled
|
||||||
|
|
||||||
|
### Social
|
||||||
|
|
||||||
|
- `heart` - like
|
||||||
|
- `heart.fill` - liked
|
||||||
|
- `star` - favorite
|
||||||
|
- `star.fill` - favorited
|
||||||
|
- `hand.thumbsup` - thumbs up
|
||||||
|
- `hand.thumbsdown` - thumbs down
|
||||||
|
- `person` - profile
|
||||||
|
- `person.fill` - profile filled
|
||||||
|
- `person.2` - people
|
||||||
|
- `person.2.fill` - people filled
|
||||||
|
|
||||||
|
### Content Actions
|
||||||
|
|
||||||
|
- `square.and.arrow.up` - share
|
||||||
|
- `square.and.arrow.down` - download
|
||||||
|
- `doc.on.doc` - copy
|
||||||
|
- `trash` - delete
|
||||||
|
- `pencil` - edit
|
||||||
|
- `folder` - folder
|
||||||
|
- `folder.fill` - folder filled
|
||||||
|
- `bookmark` - bookmark
|
||||||
|
- `bookmark.fill` - bookmarked
|
||||||
|
|
||||||
|
### Status & Feedback
|
||||||
|
|
||||||
|
- `checkmark` - success/done
|
||||||
|
- `checkmark.circle.fill` - completed
|
||||||
|
- `xmark.circle.fill` - error/failed
|
||||||
|
- `exclamationmark.triangle` - warning
|
||||||
|
- `info.circle` - info
|
||||||
|
- `questionmark.circle` - help
|
||||||
|
- `bell` - notification
|
||||||
|
- `bell.fill` - notification filled
|
||||||
|
|
||||||
|
### Misc
|
||||||
|
|
||||||
|
- `ellipsis` - more options
|
||||||
|
- `ellipsis.circle` - more in circle
|
||||||
|
- `line.3.horizontal` - menu/hamburger
|
||||||
|
- `slider.horizontal.3` - filters
|
||||||
|
- `arrow.clockwise` - refresh
|
||||||
|
- `location` - location
|
||||||
|
- `location.fill` - location filled
|
||||||
|
- `map` - map
|
||||||
|
- `mappin` - pin
|
||||||
|
- `clock` - time
|
||||||
|
- `calendar` - calendar
|
||||||
|
- `link` - link
|
||||||
|
- `nosign` - block/prohibited
|
||||||
|
|
||||||
|
## Animated Symbols
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SymbolView
|
||||||
|
name="checkmark.circle"
|
||||||
|
animationSpec={{
|
||||||
|
effect: {
|
||||||
|
type: "bounce",
|
||||||
|
direction: "up",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Animation Effects
|
||||||
|
|
||||||
|
- `bounce` - Bouncy animation
|
||||||
|
- `pulse` - Pulsing effect
|
||||||
|
- `variableColor` - Color cycling
|
||||||
|
- `scale` - Scale animation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bounce with direction
|
||||||
|
animationSpec={{
|
||||||
|
effect: { type: "bounce", direction: "up" } // up | down
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Pulse
|
||||||
|
animationSpec={{
|
||||||
|
effect: { type: "pulse" }
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Variable color (multicolor symbols)
|
||||||
|
animationSpec={{
|
||||||
|
effect: {
|
||||||
|
type: "variableColor",
|
||||||
|
cumulative: true,
|
||||||
|
reversing: true
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Symbol Weights
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Lighter weights
|
||||||
|
<SymbolView name="star" weight="ultraLight" />
|
||||||
|
<SymbolView name="star" weight="thin" />
|
||||||
|
<SymbolView name="star" weight="light" />
|
||||||
|
|
||||||
|
// Default
|
||||||
|
<SymbolView name="star" weight="regular" />
|
||||||
|
|
||||||
|
// Heavier weights
|
||||||
|
<SymbolView name="star" weight="medium" />
|
||||||
|
<SymbolView name="star" weight="semibold" />
|
||||||
|
<SymbolView name="star" weight="bold" />
|
||||||
|
<SymbolView name="star" weight="heavy" />
|
||||||
|
<SymbolView name="star" weight="black" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Symbol Scales
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SymbolView name="star" scale="small" />
|
||||||
|
<SymbolView name="star" scale="medium" /> // default
|
||||||
|
<SymbolView name="star" scale="large" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multicolor Symbols
|
||||||
|
|
||||||
|
Some symbols support multiple colors:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SymbolView name="cloud.sun.rain.fill" type="multicolor" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Finding Symbol Names
|
||||||
|
|
||||||
|
1. Use the SF Symbols app on macOS (free from Apple)
|
||||||
|
2. Search at https://developer.apple.com/sf-symbols/
|
||||||
|
3. Symbol names use dot notation: `square.and.arrow.up`
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Always use SF Symbols over vector icon libraries
|
||||||
|
- Match symbol weight to nearby text weight
|
||||||
|
- Use `.fill` variants for selected/active states
|
||||||
|
- Use PlatformColor for tint to support dark mode
|
||||||
|
- Keep icons at consistent sizes (16, 20, 24, 32)
|
||||||
229
.agents/skills/building-native-ui/references/media.md
Normal file
229
.agents/skills/building-native-ui/references/media.md
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
# Media
|
||||||
|
|
||||||
|
## Camera
|
||||||
|
|
||||||
|
- Hide navigation headers when there's a full screen camera
|
||||||
|
- Ensure to flip the camera with `mirror` to emulate social apps
|
||||||
|
- Use liquid glass buttons on cameras
|
||||||
|
- Icons: `arrow.triangle.2.circlepath` (flip), `photo` (gallery), `bolt` (flash)
|
||||||
|
- Eagerly request camera permission
|
||||||
|
- Lazily request media library permission
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import { View, TouchableOpacity, Text, Alert } from "react-native";
|
||||||
|
import { CameraView, CameraType, useCameraPermissions } from "expo-camera";
|
||||||
|
import * as MediaLibrary from "expo-media-library";
|
||||||
|
import * as ImagePicker from "expo-image-picker";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
import { SymbolView } from "expo-symbols";
|
||||||
|
import { PlatformColor } from "react-native";
|
||||||
|
import { GlassView } from "expo-glass-effect";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
function Camera({ onPicture }: { onPicture: (uri: string) => Promise<void> }) {
|
||||||
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
|
const cameraRef = useRef<CameraView>(null);
|
||||||
|
const [type, setType] = useState<CameraType>("back");
|
||||||
|
const { bottom } = useSafeAreaInsets();
|
||||||
|
|
||||||
|
if (!permission?.granted) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: PlatformColor("systemBackground"),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: PlatformColor("label"), padding: 16 }}>
|
||||||
|
Camera access is required
|
||||||
|
</Text>
|
||||||
|
<GlassView
|
||||||
|
isInteractive
|
||||||
|
tintColor={PlatformColor("systemBlue")}
|
||||||
|
style={{ borderRadius: 12 }}
|
||||||
|
>
|
||||||
|
<TouchableOpacity onPress={requestPermission} style={{ padding: 12, borderRadius: 12 }}>
|
||||||
|
<Text style={{ color: "white" }}>Grant Permission</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</GlassView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const takePhoto = async () => {
|
||||||
|
await Haptics.selectionAsync();
|
||||||
|
if (!cameraRef.current) return;
|
||||||
|
const photo = await cameraRef.current.takePictureAsync({ quality: 0.8 });
|
||||||
|
await onPicture(photo.uri);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectPhoto = async () => {
|
||||||
|
await Haptics.selectionAsync();
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: "images",
|
||||||
|
allowsEditing: false,
|
||||||
|
quality: 0.8,
|
||||||
|
});
|
||||||
|
if (!result.canceled && result.assets?.[0]) {
|
||||||
|
await onPicture(result.assets[0].uri);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "black" }}>
|
||||||
|
<CameraView ref={cameraRef} mirror style={{ flex: 1 }} facing={type} />
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: bottom,
|
||||||
|
gap: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GlassView isInteractive style={{ padding: 8, borderRadius: 99 }}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={takePhoto}
|
||||||
|
style={{ width: 64, height: 64, borderRadius: 99, backgroundColor: "white" }}
|
||||||
|
/>
|
||||||
|
</GlassView>
|
||||||
|
<View
|
||||||
|
style={{ flexDirection: "row", justifyContent: "space-around", paddingHorizontal: 8 }}
|
||||||
|
>
|
||||||
|
<GlassButton onPress={selectPhoto} icon="photo" />
|
||||||
|
<GlassButton
|
||||||
|
onPress={() => setType((t) => (t === "back" ? "front" : "back"))}
|
||||||
|
icon="arrow.triangle.2.circlepath"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Audio Playback
|
||||||
|
|
||||||
|
Use `expo-audio` not `expo-av`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useAudioPlayer } from "expo-audio";
|
||||||
|
|
||||||
|
const player = useAudioPlayer({ uri: "https://stream.nightride.fm/rektory.mp3" });
|
||||||
|
|
||||||
|
<Button title="Play" onPress={() => player.play()} />;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Audio Recording (Microphone)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
useAudioRecorder,
|
||||||
|
AudioModule,
|
||||||
|
RecordingPresets,
|
||||||
|
setAudioModeAsync,
|
||||||
|
useAudioRecorderState,
|
||||||
|
} from "expo-audio";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Alert, Button } from "react-native";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const audioRecorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
|
||||||
|
const recorderState = useAudioRecorderState(audioRecorder);
|
||||||
|
|
||||||
|
const record = async () => {
|
||||||
|
await audioRecorder.prepareToRecordAsync();
|
||||||
|
audioRecorder.record();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = () => audioRecorder.stop();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const status = await AudioModule.requestRecordingPermissionsAsync();
|
||||||
|
if (status.granted) {
|
||||||
|
setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true });
|
||||||
|
} else {
|
||||||
|
Alert.alert("Permission to access microphone was denied");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
title={recorderState.isRecording ? "Stop" : "Start"}
|
||||||
|
onPress={recorderState.isRecording ? stop : record}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Video Playback
|
||||||
|
|
||||||
|
Use `expo-video` not `expo-av`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useVideoPlayer, VideoView } from "expo-video";
|
||||||
|
import { useEvent } from "expo";
|
||||||
|
|
||||||
|
const videoSource = "https://example.com/video.mp4";
|
||||||
|
|
||||||
|
const player = useVideoPlayer(videoSource, (player) => {
|
||||||
|
player.loop = true;
|
||||||
|
player.play();
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isPlaying } = useEvent(player, "playingChange", { isPlaying: player.playing });
|
||||||
|
|
||||||
|
<VideoView player={player} fullscreenOptions={{}} allowsPictureInPicture />;
|
||||||
|
```
|
||||||
|
|
||||||
|
VideoView options:
|
||||||
|
|
||||||
|
- `allowsPictureInPicture`: boolean
|
||||||
|
- `contentFit`: 'contain' | 'cover' | 'fill'
|
||||||
|
- `nativeControls`: boolean
|
||||||
|
- `playsInline`: boolean
|
||||||
|
- `startsPictureInPictureAutomatically`: boolean
|
||||||
|
|
||||||
|
## Saving Media
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import * as MediaLibrary from "expo-media-library";
|
||||||
|
|
||||||
|
const { granted } = await MediaLibrary.requestPermissionsAsync();
|
||||||
|
if (granted) {
|
||||||
|
await MediaLibrary.saveToLibraryAsync(uri);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Saving Base64 Images
|
||||||
|
|
||||||
|
`MediaLibrary.saveToLibraryAsync` only accepts local file paths. Save base64 strings to disk first:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { File, Paths } from "expo-file-system/next";
|
||||||
|
|
||||||
|
function base64ToLocalUri(base64: string, filename?: string) {
|
||||||
|
if (!filename) {
|
||||||
|
const match = base64.match(/^data:(image\/[a-zA-Z]+);base64,/);
|
||||||
|
const ext = match ? match[1].split("/")[1] : "jpg";
|
||||||
|
filename = `generated-${Date.now()}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (base64.startsWith("data:")) base64 = base64.split(",")[1];
|
||||||
|
const binaryString = atob(base64);
|
||||||
|
const len = binaryString.length;
|
||||||
|
const bytes = new Uint8Array(new ArrayBuffer(len));
|
||||||
|
for (let i = 0; i < len; i++) bytes[i] = binaryString.charCodeAt(i);
|
||||||
|
|
||||||
|
const f = new File(Paths.cache, filename);
|
||||||
|
f.create({ overwrite: true });
|
||||||
|
f.write(bytes);
|
||||||
|
return f.uri;
|
||||||
|
}
|
||||||
|
```
|
||||||
229
.agents/skills/building-native-ui/references/route-structure.md
Normal file
229
.agents/skills/building-native-ui/references/route-structure.md
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
# Route Structure
|
||||||
|
|
||||||
|
## File Conventions
|
||||||
|
|
||||||
|
- Routes belong in the `app` directory
|
||||||
|
- Use `[]` for dynamic routes, e.g. `[id].tsx`
|
||||||
|
- Routes can never be named `(foo).tsx` - use `(foo)/index.tsx` instead
|
||||||
|
- Use `(group)` routes to simplify the public URL structure
|
||||||
|
- NEVER co-locate components, types, or utilities in the app directory - these should be in separate directories like `components/`, `utils/`, etc.
|
||||||
|
- The app directory should only contain route and `_layout` files; every file should export a default component
|
||||||
|
- Ensure the app always has a route that matches "/" so the app is never blank
|
||||||
|
- ALWAYS use `_layout.tsx` files to define stacks
|
||||||
|
|
||||||
|
## Dynamic Routes
|
||||||
|
|
||||||
|
Use square brackets for dynamic segments:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
users/
|
||||||
|
[id].tsx # Matches /users/123, /users/abc
|
||||||
|
[id]/
|
||||||
|
posts.tsx # Matches /users/123/posts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Catch-All Routes
|
||||||
|
|
||||||
|
Use `[...slug]` for catch-all routes:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
docs/
|
||||||
|
[...slug].tsx # Matches /docs/a, /docs/a/b, /docs/a/b/c
|
||||||
|
```
|
||||||
|
|
||||||
|
## Query Parameters
|
||||||
|
|
||||||
|
Access query parameters with the `useLocalSearchParams` hook:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
|
||||||
|
function Page() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For dynamic routes, the parameter name matches the file name:
|
||||||
|
|
||||||
|
- `[id].tsx` → `useLocalSearchParams<{ id: string }>()`
|
||||||
|
- `[slug].tsx` → `useLocalSearchParams<{ slug: string }>()`
|
||||||
|
|
||||||
|
## Pathname
|
||||||
|
|
||||||
|
Access the current pathname with the `usePathname` hook:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { usePathname } from "expo-router";
|
||||||
|
|
||||||
|
function Component() {
|
||||||
|
const pathname = usePathname(); // e.g. "/users/123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Group Routes
|
||||||
|
|
||||||
|
Use parentheses for groups that don't affect the URL:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
(auth)/
|
||||||
|
login.tsx # URL: /login
|
||||||
|
register.tsx # URL: /register
|
||||||
|
(main)/
|
||||||
|
index.tsx # URL: /
|
||||||
|
settings.tsx # URL: /settings
|
||||||
|
```
|
||||||
|
|
||||||
|
Groups are useful for:
|
||||||
|
|
||||||
|
- Organizing related routes
|
||||||
|
- Applying different layouts to route groups
|
||||||
|
- Keeping URLs clean
|
||||||
|
|
||||||
|
## Stacks and Tabs Structure
|
||||||
|
|
||||||
|
When an app has tabs, the header and title should be set in a Stack that is nested INSIDE each tab. This allows tabs to have their own headers and distinct histories. The root layout should often not have a header.
|
||||||
|
|
||||||
|
- Set the 'headerShown' option to false on the tab layout
|
||||||
|
- Use (group) routes to simplify the public URL structure
|
||||||
|
- You may need to delete or refactor existing routes to fit this structure
|
||||||
|
|
||||||
|
Example structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
_layout.tsx — <Tabs />
|
||||||
|
(home)/
|
||||||
|
_layout.tsx — <Stack />
|
||||||
|
index.tsx — <ScrollView />
|
||||||
|
(settings)/
|
||||||
|
_layout.tsx — <Stack />
|
||||||
|
index.tsx — <ScrollView />
|
||||||
|
(home,settings)/
|
||||||
|
info.tsx — <ScrollView /> (shared across tabs)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Array Routes for Multiple Stacks
|
||||||
|
|
||||||
|
Use array routes '(index,settings)' to create multiple stacks. This is useful for tabs that need to share screens across stacks.
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
_layout.tsx — <Tabs />
|
||||||
|
(index,settings)/
|
||||||
|
_layout.tsx — <Stack />
|
||||||
|
index.tsx — <ScrollView />
|
||||||
|
settings.tsx — <ScrollView />
|
||||||
|
```
|
||||||
|
|
||||||
|
This requires a specialized layout with explicit anchor routes:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/(index,settings)/_layout.tsx
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import Stack from "expo-router/stack";
|
||||||
|
|
||||||
|
export const unstable_settings = {
|
||||||
|
index: { anchor: "index" },
|
||||||
|
settings: { anchor: "settings" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Layout({ segment }: { segment: string }) {
|
||||||
|
const screen = segment.match(/\((.*)\)/)?.[1]!;
|
||||||
|
|
||||||
|
const options = useMemo(() => {
|
||||||
|
switch (screen) {
|
||||||
|
case "index":
|
||||||
|
return { headerRight: () => <></> };
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}, [screen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen name={screen} options={options} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete App Structure Example
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
_layout.tsx — <NativeTabs />
|
||||||
|
(index,search)/
|
||||||
|
_layout.tsx — <Stack />
|
||||||
|
index.tsx — Main list
|
||||||
|
search.tsx — Search view
|
||||||
|
i/[id].tsx — Detail page
|
||||||
|
components/
|
||||||
|
theme.tsx
|
||||||
|
list.tsx
|
||||||
|
utils/
|
||||||
|
storage.ts
|
||||||
|
use-search.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout Files
|
||||||
|
|
||||||
|
Every directory can have a `_layout.tsx` file that wraps all routes in that directory:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/_layout.tsx
|
||||||
|
import { Stack } from "expo-router/stack";
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return <Stack />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/(tabs)/_layout.tsx
|
||||||
|
import { NativeTabs, Icon, Label } from "expo-router/unstable-native-tabs";
|
||||||
|
|
||||||
|
export default function TabLayout() {
|
||||||
|
return (
|
||||||
|
<NativeTabs>
|
||||||
|
<NativeTabs.Trigger name="index">
|
||||||
|
<Label>Home</Label>
|
||||||
|
<Icon sf="house.fill" />
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
</NativeTabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Route Settings
|
||||||
|
|
||||||
|
Export `unstable_settings` to configure route behavior:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export const unstable_settings = {
|
||||||
|
anchor: "index",
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- `initialRouteName` was renamed to `anchor` in v4
|
||||||
|
|
||||||
|
## Not Found Routes
|
||||||
|
|
||||||
|
Create a `+not-found.tsx` file to handle unmatched routes:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/+not-found.tsx
|
||||||
|
import { Link } from "expo-router";
|
||||||
|
import { View, Text } from "react-native";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text>Page not found</Text>
|
||||||
|
<Link href="/">Go home</Link>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
237
.agents/skills/building-native-ui/references/search.md
Normal file
237
.agents/skills/building-native-ui/references/search.md
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
# Search
|
||||||
|
|
||||||
|
## Header Search Bar
|
||||||
|
|
||||||
|
Add a search bar to the stack header with `headerSearchBarOptions`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Stack.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
headerSearchBarOptions: {
|
||||||
|
placeholder: "Search",
|
||||||
|
onChangeText: (event) => console.log(event.nativeEvent.text),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
headerSearchBarOptions: {
|
||||||
|
// Placeholder text
|
||||||
|
placeholder: "Search items...",
|
||||||
|
|
||||||
|
// Auto-capitalize behavior
|
||||||
|
autoCapitalize: "none",
|
||||||
|
|
||||||
|
// Input type
|
||||||
|
inputType: "text", // "text" | "phone" | "number" | "email"
|
||||||
|
|
||||||
|
// Cancel button text (iOS)
|
||||||
|
cancelButtonText: "Cancel",
|
||||||
|
|
||||||
|
// Hide when scrolling (iOS)
|
||||||
|
hideWhenScrolling: true,
|
||||||
|
|
||||||
|
// Hide navigation bar during search (iOS)
|
||||||
|
hideNavigationBar: true,
|
||||||
|
|
||||||
|
// Obscure background during search (iOS)
|
||||||
|
obscureBackground: true,
|
||||||
|
|
||||||
|
// Placement
|
||||||
|
placement: "automatic", // "automatic" | "inline" | "stacked"
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
onChangeText: (event) => {},
|
||||||
|
onSearchButtonPress: (event) => {},
|
||||||
|
onCancelButtonPress: (event) => {},
|
||||||
|
onFocus: () => {},
|
||||||
|
onBlur: () => {},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## useSearch Hook
|
||||||
|
|
||||||
|
Reusable hook for search state management:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
|
||||||
|
export function useSearch(options: any = {}) {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerShown: true,
|
||||||
|
headerSearchBarOptions: {
|
||||||
|
...options,
|
||||||
|
onChangeText(e: any) {
|
||||||
|
setSearch(e.nativeEvent.text);
|
||||||
|
options.onChangeText?.(e);
|
||||||
|
},
|
||||||
|
onSearchButtonPress(e: any) {
|
||||||
|
setSearch(e.nativeEvent.text);
|
||||||
|
options.onSearchButtonPress?.(e);
|
||||||
|
},
|
||||||
|
onCancelButtonPress(e: any) {
|
||||||
|
setSearch("");
|
||||||
|
options.onCancelButtonPress?.(e);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [options, navigation]);
|
||||||
|
|
||||||
|
return search;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function SearchScreen() {
|
||||||
|
const search = useSearch({ placeholder: "Search items..." });
|
||||||
|
|
||||||
|
const filteredItems = items.filter((item) =>
|
||||||
|
item.name.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
return <FlatList data={filteredItems} renderItem={({ item }) => <ItemRow item={item} />} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Filtering Patterns
|
||||||
|
|
||||||
|
### Simple Text Filter
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const filtered = items.filter((item) => item.name.toLowerCase().includes(search.toLowerCase()));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Fields
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const filtered = items.filter((item) => {
|
||||||
|
const query = search.toLowerCase();
|
||||||
|
return (
|
||||||
|
item.name.toLowerCase().includes(query) ||
|
||||||
|
item.description.toLowerCase().includes(query) ||
|
||||||
|
item.tags.some((tag) => tag.toLowerCase().includes(query))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debounced Search
|
||||||
|
|
||||||
|
For expensive filtering or API calls:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
|
||||||
|
function useDebounce<T>(value: T, delay: number): T {
|
||||||
|
const [debounced, setDebounced] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setDebounced(value), delay);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [value, delay]);
|
||||||
|
|
||||||
|
return debounced;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchScreen() {
|
||||||
|
const search = useSearch();
|
||||||
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
|
|
||||||
|
const filteredItems = useMemo(
|
||||||
|
() => items.filter((item) => item.name.toLowerCase().includes(debouncedSearch.toLowerCase())),
|
||||||
|
[debouncedSearch],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <FlatList data={filteredItems} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Search with Native Tabs
|
||||||
|
|
||||||
|
When using NativeTabs with a search role, the search bar integrates with the tab bar:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/_layout.tsx
|
||||||
|
<NativeTabs>
|
||||||
|
<NativeTabs.Trigger name="(home)">
|
||||||
|
<Label>Home</Label>
|
||||||
|
<Icon sf="house.fill" />
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
<NativeTabs.Trigger name="(search)" role="search">
|
||||||
|
<Label>Search</Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
</NativeTabs>
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/(search)/_layout.tsx
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
headerSearchBarOptions: {
|
||||||
|
placeholder: "Search...",
|
||||||
|
onChangeText: (e) => setSearch(e.nativeEvent.text),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Empty States
|
||||||
|
|
||||||
|
Show appropriate UI when search returns no results:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function SearchResults({ search, items }) {
|
||||||
|
const filtered = items.filter(/* ... */);
|
||||||
|
|
||||||
|
if (search && filtered.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||||
|
<Text style={{ color: PlatformColor("secondaryLabel") }}>No results for "{search}"</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <FlatList data={filtered} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Search Suggestions
|
||||||
|
|
||||||
|
Show recent searches or suggestions:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function SearchScreen() {
|
||||||
|
const search = useSearch();
|
||||||
|
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
||||||
|
|
||||||
|
if (!search && recentSearches.length > 0) {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text style={{ color: PlatformColor("secondaryLabel") }}>
|
||||||
|
Recent Searches
|
||||||
|
</Text>
|
||||||
|
{recentSearches.map((term) => (
|
||||||
|
<Pressable key={term} onPress={() => /* apply search */}>
|
||||||
|
<Text>{term}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SearchResults search={search} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
110
.agents/skills/building-native-ui/references/storage.md
Normal file
110
.agents/skills/building-native-ui/references/storage.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# Storage
|
||||||
|
|
||||||
|
## Key-Value Storage
|
||||||
|
|
||||||
|
Use the localStorage polyfill for key-value storage. **Never use AsyncStorage**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import "expo-sqlite/localStorage/install";
|
||||||
|
|
||||||
|
// Simple get/set
|
||||||
|
localStorage.setItem("key", "value");
|
||||||
|
localStorage.getItem("key");
|
||||||
|
|
||||||
|
// Store objects as JSON
|
||||||
|
localStorage.setItem("user", JSON.stringify({ name: "John", id: 1 }));
|
||||||
|
const user = JSON.parse(localStorage.getItem("user") ?? "{}");
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to Use What
|
||||||
|
|
||||||
|
| Use Case | Solution |
|
||||||
|
| ---------------------------------------------------- | ----------------------- |
|
||||||
|
| Simple key-value (settings, preferences, small data) | `localStorage` polyfill |
|
||||||
|
| Large datasets, complex queries, relational data | Full `expo-sqlite` |
|
||||||
|
| Sensitive data (tokens, passwords) | `expo-secure-store` |
|
||||||
|
|
||||||
|
## Storage with React State
|
||||||
|
|
||||||
|
Create a storage utility with subscriptions for reactive updates:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// utils/storage.ts
|
||||||
|
import "expo-sqlite/localStorage/install";
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
const listeners = new Map<string, Set<Listener>>();
|
||||||
|
|
||||||
|
export const storage = {
|
||||||
|
get<T>(key: string, defaultValue: T): T {
|
||||||
|
const value = localStorage.getItem(key);
|
||||||
|
return value ? JSON.parse(value) : defaultValue;
|
||||||
|
},
|
||||||
|
|
||||||
|
set<T>(key: string, value: T): void {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
listeners.get(key)?.forEach((fn) => fn());
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribe(key: string, listener: Listener): () => void {
|
||||||
|
if (!listeners.has(key)) listeners.set(key, new Set());
|
||||||
|
listeners.get(key)!.add(listener);
|
||||||
|
return () => listeners.get(key)?.delete(listener);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## React Hook for Storage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// hooks/use-storage.ts
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { storage } from "@/utils/storage";
|
||||||
|
|
||||||
|
export function useStorage<T>(key: string, defaultValue: T): [T, (value: T) => void] {
|
||||||
|
const value = useSyncExternalStore(
|
||||||
|
(cb) => storage.subscribe(key, cb),
|
||||||
|
() => storage.get(key, defaultValue),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [value, (newValue: T) => storage.set(key, newValue)];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Settings() {
|
||||||
|
const [theme, setTheme] = useStorage("theme", "light");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch value={theme === "dark"} onValueChange={(dark) => setTheme(dark ? "dark" : "light")} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Full SQLite for Complex Data
|
||||||
|
|
||||||
|
For larger datasets or complex queries, use expo-sqlite directly:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import * as SQLite from "expo-sqlite";
|
||||||
|
|
||||||
|
const db = await SQLite.openDatabaseAsync("app.db");
|
||||||
|
|
||||||
|
// Create table
|
||||||
|
await db.execAsync(`
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
location TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Insert
|
||||||
|
await db.runAsync("INSERT INTO events (title, date) VALUES (?, ?)", ["Meeting", "2024-01-15"]);
|
||||||
|
|
||||||
|
// Query
|
||||||
|
const events = await db.getAllAsync("SELECT * FROM events WHERE date > ?", ["2024-01-01"]);
|
||||||
|
```
|
||||||
417
.agents/skills/building-native-ui/references/tabs.md
Normal file
417
.agents/skills/building-native-ui/references/tabs.md
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
# Native Tabs
|
||||||
|
|
||||||
|
Always prefer NativeTabs from 'expo-router/unstable-native-tabs' for the best iOS experience.
|
||||||
|
|
||||||
|
**SDK 54+. SDK 55 recommended.**
|
||||||
|
|
||||||
|
## SDK Compatibility
|
||||||
|
|
||||||
|
| Aspect | SDK 54 | SDK 55+ |
|
||||||
|
| ------------- | ------------------------------------------------------- | ----------------------------------------------------------- |
|
||||||
|
| Import | `import { NativeTabs, Icon, Label, Badge, VectorIcon }` | `import { NativeTabs }` only |
|
||||||
|
| Icon | `<Icon sf="house.fill" />` | `<NativeTabs.Trigger.Icon sf="house.fill" />` |
|
||||||
|
| Label | `<Label>Home</Label>` | `<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>` |
|
||||||
|
| Badge | `<Badge>9+</Badge>` | `<NativeTabs.Trigger.Badge>9+</NativeTabs.Trigger.Badge>` |
|
||||||
|
| Android icons | `drawable` prop | `md` prop (Material Symbols) |
|
||||||
|
|
||||||
|
All examples below use SDK 55 syntax. For SDK 54, replace `NativeTabs.Trigger.Icon/Label/Badge` with standalone `Icon`, `Label`, `Badge` imports.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||||
|
|
||||||
|
export default function TabLayout() {
|
||||||
|
return (
|
||||||
|
<NativeTabs minimizeBehavior="onScrollDown">
|
||||||
|
<NativeTabs.Trigger name="index">
|
||||||
|
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
|
||||||
|
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||||
|
<NativeTabs.Trigger.Badge>9+</NativeTabs.Trigger.Badge>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
<NativeTabs.Trigger name="settings">
|
||||||
|
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
|
||||||
|
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
<NativeTabs.Trigger name="(search)" role="search">
|
||||||
|
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
</NativeTabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- You must include a trigger for each tab
|
||||||
|
- The `NativeTabs.Trigger` 'name' must match the route name, including parentheses (e.g. `<NativeTabs.Trigger name="(search)">`)
|
||||||
|
- Prefer search tab to be last in the list so it can combine with the search bar
|
||||||
|
- Use the 'role' prop for common tab types
|
||||||
|
- Tabs must be static — no dynamic addition/removal at runtime (remounts navigator, loses state)
|
||||||
|
|
||||||
|
## Platform Features
|
||||||
|
|
||||||
|
Native Tabs use platform-specific tab bar implementations:
|
||||||
|
|
||||||
|
- **iOS 26+**: Liquid glass effects with system-native appearance
|
||||||
|
- **Android**: Material 3 bottom navigation
|
||||||
|
- Better performance and native feel
|
||||||
|
|
||||||
|
## Icon Component
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// SF Symbol (iOS) + Material Symbol (Android)
|
||||||
|
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
|
||||||
|
|
||||||
|
// State variants
|
||||||
|
<NativeTabs.Trigger.Icon sf={{ default: "house", selected: "house.fill" }} md="home" />
|
||||||
|
|
||||||
|
// Custom image
|
||||||
|
<NativeTabs.Trigger.Icon src={require('./icon.png')} />
|
||||||
|
|
||||||
|
// Xcode asset catalog — iOS only (SDK 55+)
|
||||||
|
<NativeTabs.Trigger.Icon xcasset="home-icon" />
|
||||||
|
<NativeTabs.Trigger.Icon xcasset={{ default: "home-outline", selected: "home-filled" }} />
|
||||||
|
|
||||||
|
// Rendering mode — iOS only (SDK 55+)
|
||||||
|
<NativeTabs.Trigger.Icon src={require('./icon.png')} renderingMode="template" />
|
||||||
|
<NativeTabs.Trigger.Icon src={require('./gradient.png')} renderingMode="original" />
|
||||||
|
```
|
||||||
|
|
||||||
|
`renderingMode`: `"template"` applies tint color (single-color icons), `"original"` preserves source colors (gradients). Android always uses original.
|
||||||
|
|
||||||
|
## Label & Badge
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Label
|
||||||
|
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||||
|
<NativeTabs.Trigger.Label hidden>Home</NativeTabs.Trigger.Label> {/* icon-only tab */}
|
||||||
|
|
||||||
|
// Badge
|
||||||
|
<NativeTabs.Trigger.Badge>9+</NativeTabs.Trigger.Badge>
|
||||||
|
<NativeTabs.Trigger.Badge /> {/* dot indicator */}
|
||||||
|
```
|
||||||
|
|
||||||
|
## iOS 26 Features
|
||||||
|
|
||||||
|
### Liquid Glass Tab Bar
|
||||||
|
|
||||||
|
The tab bar automatically adopts liquid glass appearance on iOS 26+.
|
||||||
|
|
||||||
|
### Minimize on Scroll
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<NativeTabs minimizeBehavior="onScrollDown">
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search Tab
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<NativeTabs.Trigger name="(search)" role="search">
|
||||||
|
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Place search tab last for best UX.
|
||||||
|
|
||||||
|
### Role Prop
|
||||||
|
|
||||||
|
Use semantic roles for special tab types:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<NativeTabs.Trigger name="search" role="search" />
|
||||||
|
<NativeTabs.Trigger name="favorites" role="favorites" />
|
||||||
|
<NativeTabs.Trigger name="more" role="more" />
|
||||||
|
```
|
||||||
|
|
||||||
|
Available roles: `search` | `more` | `favorites` | `bookmarks` | `contacts` | `downloads` | `featured` | `history` | `mostRecent` | `mostViewed` | `recents` | `topRated`
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
### Tint Color
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<NativeTabs tintColor="#007AFF">
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dynamic Colors (iOS)
|
||||||
|
|
||||||
|
Use DynamicColorIOS for colors that adapt to liquid glass:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { DynamicColorIOS, Platform } from 'react-native';
|
||||||
|
|
||||||
|
const adaptiveBlue = Platform.select({
|
||||||
|
ios: DynamicColorIOS({ light: '#007AFF', dark: '#0A84FF' }),
|
||||||
|
default: '#007AFF',
|
||||||
|
});
|
||||||
|
|
||||||
|
<NativeTabs tintColor={adaptiveBlue}>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conditional Tabs
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<NativeTabs.Trigger name="admin" hidden={!isAdmin}>
|
||||||
|
<NativeTabs.Trigger.Label>Admin</NativeTabs.Trigger.Label>
|
||||||
|
<NativeTabs.Trigger.Icon sf="shield.fill" md="shield" />
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Don't hide the tabs when they are visible - toggling visibility remounts the navigator; Do it only during the initial render.**
|
||||||
|
|
||||||
|
**Note**: Hidden tabs cannot be navigated to!
|
||||||
|
|
||||||
|
## Behavior Options
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<NativeTabs.Trigger
|
||||||
|
name="home"
|
||||||
|
disablePopToTop // Don't pop stack when tapping active tab
|
||||||
|
disableScrollToTop // Don't scroll to top when tapping active tab
|
||||||
|
disableAutomaticContentInsets // Opt out of automatic safe area insets (SDK 55+)
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hidden Tab Bar (SDK 55+)
|
||||||
|
|
||||||
|
Use `hidden` prop on `NativeTabs` to hide the entire tab bar dynamically:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<NativeTabs hidden={isTabBarHidden}>{/* triggers */}</NativeTabs>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bottom Accessory (SDK 55+)
|
||||||
|
|
||||||
|
`NativeTabs.BottomAccessory` renders content above the tab bar (iOS 26+). Uses `usePlacement()` to adapt between `'regular'` and `'inline'` layouts.
|
||||||
|
|
||||||
|
**Important**: Two instances render simultaneously — store state outside the component (props, context, or external store).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
|
||||||
|
function MiniPlayer({ isPlaying, onToggle }: { isPlaying: boolean; onToggle: () => void }) {
|
||||||
|
const placement = NativeTabs.BottomAccessory.usePlacement();
|
||||||
|
if (placement === "inline") {
|
||||||
|
return (
|
||||||
|
<Pressable onPress={onToggle}>
|
||||||
|
<SymbolView name={isPlaying ? "pause.fill" : "play.fill"} />
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <View>{/* full player UI */}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TabLayout() {
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
return (
|
||||||
|
<NativeTabs>
|
||||||
|
<NativeTabs.BottomAccessory>
|
||||||
|
<MiniPlayer isPlaying={isPlaying} onToggle={() => setIsPlaying(!isPlaying)} />
|
||||||
|
</NativeTabs.BottomAccessory>
|
||||||
|
<NativeTabs.Trigger name="index">
|
||||||
|
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
|
||||||
|
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
</NativeTabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Safe Area Handling (SDK 55+)
|
||||||
|
|
||||||
|
SDK 55 handles safe areas automatically:
|
||||||
|
|
||||||
|
- **Android**: Content wrapped in SafeAreaView (bottom inset)
|
||||||
|
- **iOS**: First ScrollView gets automatic `contentInsetAdjustmentBehavior`
|
||||||
|
|
||||||
|
To opt out per-tab, use `disableAutomaticContentInsets` and manage manually:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<NativeTabs.Trigger name="index" disableAutomaticContentInsets>
|
||||||
|
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// In the screen
|
||||||
|
import { SafeAreaView } from "react-native-screens/experimental";
|
||||||
|
|
||||||
|
export default function HomeScreen() {
|
||||||
|
return (
|
||||||
|
<SafeAreaView edges={{ bottom: true }} style={{ flex: 1 }}>
|
||||||
|
{/* content */}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using Vector Icons
|
||||||
|
|
||||||
|
If you must use @expo/vector-icons instead of SF Symbols:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||||
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
|
|
||||||
|
<NativeTabs.Trigger name="home">
|
||||||
|
<NativeTabs.Trigger.VectorIcon vector={Ionicons} name="home" />
|
||||||
|
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||||
|
</NativeTabs.Trigger>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prefer SF Symbols + `md` prop over vector icons for native feel.**
|
||||||
|
|
||||||
|
If you are using SDK 55 and later **use the md prop to specify Material Symbols used on Android**.
|
||||||
|
|
||||||
|
## Structure with Stacks
|
||||||
|
|
||||||
|
Native tabs don't render headers. Nest Stacks inside each tab for navigation headers:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/(tabs)/_layout.tsx
|
||||||
|
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||||
|
|
||||||
|
export default function TabLayout() {
|
||||||
|
return (
|
||||||
|
<NativeTabs>
|
||||||
|
<NativeTabs.Trigger name="(home)">
|
||||||
|
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||||
|
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
</NativeTabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// app/(tabs)/(home)/_layout.tsx
|
||||||
|
import Stack from "expo-router/stack";
|
||||||
|
|
||||||
|
export default function HomeStack() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen name="index" options={{ title: "Home", headerLargeTitle: true }} />
|
||||||
|
<Stack.Screen name="details" options={{ title: "Details" }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Web Layout
|
||||||
|
|
||||||
|
Use platform-specific files for separate native and web tab layouts:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
_layout.tsx # NativeTabs for iOS/Android
|
||||||
|
_layout.web.tsx # Headless tabs for web (expo-router/ui)
|
||||||
|
```
|
||||||
|
|
||||||
|
Or extract to a component: `components/app-tabs.tsx` + `components/app-tabs.web.tsx`.
|
||||||
|
|
||||||
|
## Migration from JS Tabs
|
||||||
|
|
||||||
|
### Before (JS Tabs)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Tabs } from "expo-router";
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: "Home",
|
||||||
|
tabBarIcon: ({ color }) => <IconSymbol name="house.fill" color={color} />,
|
||||||
|
tabBarBadge: 3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (Native Tabs)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||||
|
|
||||||
|
<NativeTabs>
|
||||||
|
<NativeTabs.Trigger name="index">
|
||||||
|
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||||
|
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
|
||||||
|
<NativeTabs.Trigger.Badge>3</NativeTabs.Trigger.Badge>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
</NativeTabs>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Differences
|
||||||
|
|
||||||
|
| JS Tabs | Native Tabs |
|
||||||
|
| -------------------------- | ---------------------------- |
|
||||||
|
| `<Tabs.Screen>` | `<NativeTabs.Trigger>` |
|
||||||
|
| `options={{ title }}` | `<NativeTabs.Trigger.Label>` |
|
||||||
|
| `options={{ tabBarIcon }}` | `<NativeTabs.Trigger.Icon>` |
|
||||||
|
| `tabBarBadge` option | `<NativeTabs.Trigger.Badge>` |
|
||||||
|
| Props-based API | Component-based API |
|
||||||
|
| Headers built-in | Nest `<Stack>` for headers |
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- **Android**: Maximum 5 tabs (Material Design constraint)
|
||||||
|
- **Nesting**: Native tabs cannot nest inside other native tabs
|
||||||
|
- **Tab bar height**: Cannot be measured programmatically
|
||||||
|
- **FlatList transparency**: Use `disableTransparentOnScrollEdge` to fix issues
|
||||||
|
- **Dynamic tabs**: Tabs must be static; changes remount navigator and lose state
|
||||||
|
|
||||||
|
## Keyboard Handling (Android)
|
||||||
|
|
||||||
|
Configure in app.json:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"android": {
|
||||||
|
"softwareKeyboardLayoutMode": "resize"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
1. **Icons not showing on Android**: Add `md` prop (SDK 55) or use VectorIcon
|
||||||
|
2. **Headers missing**: Nest a Stack inside each tab group
|
||||||
|
3. **Trigger name mismatch**: `name` must match exact route name including parentheses
|
||||||
|
4. **Badge not visible**: Badge must be a child of Trigger, not a prop
|
||||||
|
5. **Tab bar transparent on iOS 18 and earlier**: If the screen uses a `ScrollView` or `FlatList`, make sure it is the first opaque child of the screen component. If it needs to be wrapped in another `View`, ensure the wrapper uses `collapsable={false}`. If the screen does not use a `ScrollView` or `FlatList`, set `disableTransparentOnScrollEdge` to `true` in the `NativeTabs.Trigger` options, to make the tab bar opaque.
|
||||||
|
6. **Scroll to top not working**: Ensure `disableScrollToTop` is not set on the active tab's Trigger and `ScrollView` is the first child of the screen component.
|
||||||
|
7. **Header buttons flicker when navigating between tabs**: Make sure the app is wrapped in a `ThemeProvider`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ThemeProvider, DarkTheme, DefaultTheme } from "@react-navigation/native";
|
||||||
|
import { useColorScheme } from "react-native";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
|
||||||
|
<Stack />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the app only uses a light or dark theme, you can directly pass `DarkTheme` or `DefaultTheme` to `ThemeProvider` without checking the color scheme.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ThemeProvider, DarkTheme } from "@react-navigation/native";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={DarkTheme}>
|
||||||
|
<Stack />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
# Toolbars and headers
|
||||||
|
|
||||||
|
Add native iOS toolbar items to Stack screens. Items can be placed in the header (left/right) or in a bottom toolbar area.
|
||||||
|
|
||||||
|
**Important:** iOS only. Available in Expo SDK 55+.
|
||||||
|
|
||||||
|
## Notes app example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { ScrollView } from "react-native";
|
||||||
|
|
||||||
|
export default function FoldersScreen() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* ScrollView must be the first child of the screen */}
|
||||||
|
<ScrollView style={{ flex: 1 }} contentInsetAdjustmentBehavior="automatic">
|
||||||
|
{/* Screen content */}
|
||||||
|
</ScrollView>
|
||||||
|
<Stack.Screen.Title large>Folders</Stack.Screen.Title>
|
||||||
|
<Stack.SearchBar placeholder="Search" onChangeText={() => {}} />
|
||||||
|
{/* Header toolbar - right side */}
|
||||||
|
<Stack.Toolbar placement="right">
|
||||||
|
<Stack.Toolbar.Button icon="folder.badge.plus" onPress={() => {}} />
|
||||||
|
<Stack.Toolbar.Button onPress={() => {}}>Edit</Stack.Toolbar.Button>
|
||||||
|
</Stack.Toolbar>
|
||||||
|
|
||||||
|
{/* Bottom toolbar */}
|
||||||
|
<Stack.Toolbar placement="bottom">
|
||||||
|
<Stack.Toolbar.SearchBarSlot />
|
||||||
|
<Stack.Toolbar.Button icon="square.and.pencil" onPress={() => {}} separateBackground />
|
||||||
|
</Stack.Toolbar>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mail inbox example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Color, Stack } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ScrollView, Text, View } from "react-native";
|
||||||
|
|
||||||
|
export default function InboxScreen() {
|
||||||
|
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 16 }}
|
||||||
|
>
|
||||||
|
{/* Screen content */}
|
||||||
|
</ScrollView>
|
||||||
|
<Stack.Screen options={{ headerTransparent: true }} />
|
||||||
|
<Stack.Screen.Title>Inbox</Stack.Screen.Title>
|
||||||
|
<Stack.SearchBar placeholder="Search" onChangeText={() => {}} />
|
||||||
|
{/* Header toolbar - right side */}
|
||||||
|
<Stack.Toolbar placement="right">
|
||||||
|
<Stack.Toolbar.Button onPress={() => {}}>Select</Stack.Toolbar.Button>
|
||||||
|
<Stack.Toolbar.Menu icon="ellipsis">
|
||||||
|
<Stack.Toolbar.Menu inline>
|
||||||
|
<Stack.Toolbar.Menu inline title="Sort By">
|
||||||
|
<Stack.Toolbar.MenuAction isOn>Categories</Stack.Toolbar.MenuAction>
|
||||||
|
<Stack.Toolbar.MenuAction>List</Stack.Toolbar.MenuAction>
|
||||||
|
</Stack.Toolbar.Menu>
|
||||||
|
<Stack.Toolbar.MenuAction icon="info.circle">About categories</Stack.Toolbar.MenuAction>
|
||||||
|
</Stack.Toolbar.Menu>
|
||||||
|
<Stack.Toolbar.MenuAction icon="person.circle">
|
||||||
|
Show Contact Photos
|
||||||
|
</Stack.Toolbar.MenuAction>
|
||||||
|
</Stack.Toolbar.Menu>
|
||||||
|
</Stack.Toolbar>
|
||||||
|
|
||||||
|
{/* Bottom toolbar */}
|
||||||
|
<Stack.Toolbar placement="bottom">
|
||||||
|
<Stack.Toolbar.Button
|
||||||
|
icon="line.3.horizontal.decrease"
|
||||||
|
selected={isFilterOpen}
|
||||||
|
onPress={() => setIsFilterOpen((prev) => !prev)}
|
||||||
|
/>
|
||||||
|
<Stack.Toolbar.View hidden={!isFilterOpen}>
|
||||||
|
<View style={{ width: 70, height: 32, justifyContent: "center" }}>
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: 700 }}>Filter by</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: Color.ios.systemBlue,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Unread
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Stack.Toolbar.View>
|
||||||
|
<Stack.Toolbar.Spacer />
|
||||||
|
<Stack.Toolbar.SearchBarSlot />
|
||||||
|
<Stack.Toolbar.Button icon="square.and.pencil" onPress={() => {}} separateBackground />
|
||||||
|
</Stack.Toolbar>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Placement
|
||||||
|
|
||||||
|
- `"left"` - Header left
|
||||||
|
- `"right"` - Header right
|
||||||
|
- `"bottom"` (default) - Bottom toolbar
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Button
|
||||||
|
|
||||||
|
- Icon button: `<Stack.Toolbar.Button icon="star.fill" onPress={() => {}} />`
|
||||||
|
- Text button: `<Stack.Toolbar.Button onPress={() => {}}>Done</Stack.Toolbar.Button>`
|
||||||
|
|
||||||
|
**Props:** `icon`, `image`, `onPress`, `disabled`, `hidden`, `variant` (`"plain"` | `"done"` | `"prominent"`), `tintColor`
|
||||||
|
|
||||||
|
### Menu
|
||||||
|
|
||||||
|
Dropdown menu for grouping actions.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Stack.Toolbar.Menu icon="ellipsis">
|
||||||
|
<Stack.Toolbar.Menu inline>
|
||||||
|
<Stack.Toolbar.MenuAction>Sort by Recently Added</Stack.Toolbar.MenuAction>
|
||||||
|
<Stack.Toolbar.MenuAction isOn>Sort by Date Captured</Stack.Toolbar.MenuAction>
|
||||||
|
</Stack.Toolbar.Menu>
|
||||||
|
<Stack.Toolbar.Menu title="Filter">
|
||||||
|
<Stack.Toolbar.Menu inline>
|
||||||
|
<Stack.Toolbar.MenuAction isOn icon="square.grid.2x2">
|
||||||
|
All Items
|
||||||
|
</Stack.Toolbar.MenuAction>
|
||||||
|
</Stack.Toolbar.Menu>
|
||||||
|
<Stack.Toolbar.MenuAction icon="heart">Favorites</Stack.Toolbar.MenuAction>
|
||||||
|
<Stack.Toolbar.MenuAction icon="photo">Photos</Stack.Toolbar.MenuAction>
|
||||||
|
<Stack.Toolbar.MenuAction icon="video">Videos</Stack.Toolbar.MenuAction>
|
||||||
|
</Stack.Toolbar.Menu>
|
||||||
|
</Stack.Toolbar.Menu>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Menu Props:** All Button props plus `title`, `inline`, `palette`, `elementSize` (`"small"` | `"medium"` | `"large"`)
|
||||||
|
|
||||||
|
**MenuAction Props:** `icon`, `onPress`, `isOn`, `destructive`, `disabled`, `subtitle`
|
||||||
|
|
||||||
|
When creating a palette with dividers, use `inline` combined with `elementSize="small"`. `palette` will not apply dividers on iOS 26.
|
||||||
|
|
||||||
|
### Spacer
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Stack.Toolbar.Spacer /> // Bottom toolbar - flexible
|
||||||
|
<Stack.Toolbar.Spacer width={16} /> // Header - requires explicit width
|
||||||
|
```
|
||||||
|
|
||||||
|
### View
|
||||||
|
|
||||||
|
Embed custom React Native components. When adding a custom view make sure that there is only a single child with **explicit width and height**.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Stack.Toolbar.View>
|
||||||
|
<View style={{ width: 70, height: 32, justifyContent: "center" }}>
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: 700 }}>Filter by</Text>
|
||||||
|
</View>
|
||||||
|
</Stack.Toolbar.View>
|
||||||
|
```
|
||||||
|
|
||||||
|
You can pass custom components to views as well:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function CustomFilterView() {
|
||||||
|
return (
|
||||||
|
<View style={{ width: 70, height: 32, justifyContent: "center" }}>
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: 700 }}>Filter by</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
...
|
||||||
|
<Stack.Toolbar.View>
|
||||||
|
<CustomFilterView />
|
||||||
|
</Stack.Toolbar.View>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
- When creating more complex headers, extract them to a single component
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ScrollView>{/* Screen content */}</ScrollView>
|
||||||
|
<InboxHeader />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InboxHeader() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen.Title>Inbox</Stack.Screen.Title>
|
||||||
|
<Stack.SearchBar placeholder="Search" onChangeText={() => {}} />
|
||||||
|
<Stack.Toolbar placement="right">{/* Toolbar buttons */}</Stack.Toolbar>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- When using `Stack.Toolbar`, make sure that all `Stack.Toolbar.*` components are wrapped inside `Stack.Toolbar` component.
|
||||||
|
|
||||||
|
This will **not work**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Buttons() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Toolbar.Button icon="star.fill" onPress={() => {}} />
|
||||||
|
<Stack.Toolbar.Button onPress={() => {}}>Done</Stack.Toolbar.Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ScrollView>{/* Screen content */}</ScrollView>
|
||||||
|
<Stack.Toolbar placement="right">
|
||||||
|
<Buttons /> {/* ❌ This will NOT work */}
|
||||||
|
</Stack.Toolbar>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This will work:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ToolbarWithButtons() {
|
||||||
|
return (
|
||||||
|
<Stack.Toolbar>
|
||||||
|
<Stack.Toolbar.Button icon="star.fill" onPress={() => {}} />
|
||||||
|
<Stack.Toolbar.Button onPress={() => {}}>Done</Stack.Toolbar.Button>
|
||||||
|
</Stack.Toolbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ScrollView>{/* Screen content */}</ScrollView>
|
||||||
|
<ToolbarWithButtons /> {/* ✅ This will work */}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- iOS only
|
||||||
|
- `placement="bottom"` can only be used inside screen components (not in layout files)
|
||||||
|
- `Stack.Toolbar.Badge` only works with `placement="left"` or `"right"`
|
||||||
|
- Header Spacers require explicit `width`
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
Docs https://docs.expo.dev/versions/unversioned/sdk/router - read to see the full API.
|
||||||
195
.agents/skills/building-native-ui/references/visual-effects.md
Normal file
195
.agents/skills/building-native-ui/references/visual-effects.md
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
# Visual Effects
|
||||||
|
|
||||||
|
## Backdrop Blur
|
||||||
|
|
||||||
|
Use `expo-blur` for blur effects. Prefer systemMaterial tints as they adapt to dark mode.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
|
||||||
|
<BlurView tint="systemMaterial" intensity={100} />;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tint Options
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// System materials (adapt to dark mode)
|
||||||
|
<BlurView tint="systemMaterial" />
|
||||||
|
<BlurView tint="systemThinMaterial" />
|
||||||
|
<BlurView tint="systemUltraThinMaterial" />
|
||||||
|
<BlurView tint="systemThickMaterial" />
|
||||||
|
<BlurView tint="systemChromeMaterial" />
|
||||||
|
|
||||||
|
// Basic tints
|
||||||
|
<BlurView tint="light" />
|
||||||
|
<BlurView tint="dark" />
|
||||||
|
<BlurView tint="default" />
|
||||||
|
|
||||||
|
// Prominent (more visible)
|
||||||
|
<BlurView tint="prominent" />
|
||||||
|
|
||||||
|
// Extra light/dark
|
||||||
|
<BlurView tint="extraLight" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Intensity
|
||||||
|
|
||||||
|
Control blur strength with `intensity` (0-100):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<BlurView tint="systemMaterial" intensity={50} /> // Subtle
|
||||||
|
<BlurView tint="systemMaterial" intensity={100} /> // Full
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rounded Corners
|
||||||
|
|
||||||
|
BlurView requires `overflow: 'hidden'` to clip rounded corners:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<BlurView
|
||||||
|
tint="systemMaterial"
|
||||||
|
intensity={100}
|
||||||
|
style={{
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Overlay Pattern
|
||||||
|
|
||||||
|
Common pattern for overlaying blur on content:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<View style={{ position: "relative" }}>
|
||||||
|
<Image source={{ uri: "..." }} style={{ width: "100%", height: 200 }} />
|
||||||
|
<BlurView
|
||||||
|
tint="systemUltraThinMaterial"
|
||||||
|
intensity={80}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
padding: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: "white" }}>Caption</Text>
|
||||||
|
</BlurView>
|
||||||
|
</View>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Glass Effects (iOS 26+)
|
||||||
|
|
||||||
|
Use `expo-glass-effect` for liquid glass backdrops on iOS 26+.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { GlassView } from "expo-glass-effect";
|
||||||
|
|
||||||
|
<GlassView style={{ borderRadius: 16, padding: 16 }}>
|
||||||
|
<Text>Content inside glass</Text>
|
||||||
|
</GlassView>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interactive Glass
|
||||||
|
|
||||||
|
Add `isInteractive` for buttons and pressable glass:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { GlassView } from "expo-glass-effect";
|
||||||
|
import { SymbolView } from "expo-symbols";
|
||||||
|
import { PlatformColor } from "react-native";
|
||||||
|
|
||||||
|
<GlassView isInteractive style={{ borderRadius: 50 }}>
|
||||||
|
<Pressable style={{ padding: 12 }} onPress={handlePress}>
|
||||||
|
<SymbolView name="plus" tintColor={PlatformColor("label")} size={36} />
|
||||||
|
</Pressable>
|
||||||
|
</GlassView>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Glass Buttons
|
||||||
|
|
||||||
|
Create liquid glass buttons:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function GlassButton({ icon, onPress }) {
|
||||||
|
return (
|
||||||
|
<GlassView isInteractive style={{ borderRadius: 50 }}>
|
||||||
|
<Pressable style={{ padding: 12 }} onPress={onPress}>
|
||||||
|
<SymbolView name={icon} tintColor={PlatformColor("label")} size={24} />
|
||||||
|
</Pressable>
|
||||||
|
</GlassView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
<GlassButton icon="plus" onPress={handleAdd} />
|
||||||
|
<GlassButton icon="gear" onPress={handleSettings} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Glass Card
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<GlassView style={{ borderRadius: 20, padding: 20 }}>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: "600", color: PlatformColor("label") }}>Card Title</Text>
|
||||||
|
<Text style={{ color: PlatformColor("secondaryLabel"), marginTop: 8 }}>
|
||||||
|
Card content goes here
|
||||||
|
</Text>
|
||||||
|
</GlassView>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checking Availability
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||||
|
|
||||||
|
if (isLiquidGlassAvailable()) {
|
||||||
|
// Use GlassView
|
||||||
|
} else {
|
||||||
|
// Fallback to BlurView or solid background
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fallback Pattern
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
|
||||||
|
function AdaptiveGlass({ children, style }) {
|
||||||
|
if (isLiquidGlassAvailable()) {
|
||||||
|
return <GlassView style={style}>{children}</GlassView>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BlurView tint="systemMaterial" intensity={80} style={style}>
|
||||||
|
{children}
|
||||||
|
</BlurView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sheet with Glass Background
|
||||||
|
|
||||||
|
Make sheet backgrounds liquid glass on iOS 26+:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Stack.Screen
|
||||||
|
name="sheet"
|
||||||
|
options={{
|
||||||
|
presentation: "formSheet",
|
||||||
|
sheetGrabberVisible: true,
|
||||||
|
sheetAllowedDetents: [0.5, 1.0],
|
||||||
|
contentStyle: { backgroundColor: "transparent" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Use `systemMaterial` tints for automatic dark mode support
|
||||||
|
- Always set `overflow: 'hidden'` on BlurView for rounded corners
|
||||||
|
- Use `isInteractive` on GlassView for buttons and pressables
|
||||||
|
- Check `isLiquidGlassAvailable()` and provide fallbacks
|
||||||
|
- Avoid nesting blur views (performance impact)
|
||||||
|
- Keep blur intensity reasonable (50-100) for readability
|
||||||
589
.agents/skills/building-native-ui/references/webgpu-three.md
Normal file
589
.agents/skills/building-native-ui/references/webgpu-three.md
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
# WebGPU & Three.js for Expo
|
||||||
|
|
||||||
|
**Use this skill for ANY 3D graphics, games, GPU compute, or Three.js features in React Native.**
|
||||||
|
|
||||||
|
## Locked Versions (Tested & Working)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"react-native-wgpu": "^0.4.1",
|
||||||
|
"three": "0.172.0",
|
||||||
|
"@react-three/fiber": "^9.4.0",
|
||||||
|
"wgpu-matrix": "^3.0.2",
|
||||||
|
"@types/three": "0.172.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical:** These versions are tested together. Mismatched versions cause type errors and runtime issues.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install react-native-wgpu@^0.4.1 three@0.172.0 @react-three/fiber@^9.4.0 wgpu-matrix@^3.0.2 @types/three@0.172.0 --legacy-peer-deps
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** `--legacy-peer-deps` may be required due to peer dependency conflicts with canary Expo versions.
|
||||||
|
|
||||||
|
## Metro Configuration
|
||||||
|
|
||||||
|
Create `metro.config.js` in project root:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { getDefaultConfig } = require("expo/metro-config");
|
||||||
|
|
||||||
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
|
config.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||||
|
// Force 'three' to webgpu build
|
||||||
|
if (moduleName.startsWith("three")) {
|
||||||
|
moduleName = "three/webgpu";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use standard react-three/fiber instead of React Native version
|
||||||
|
if (platform !== "web" && moduleName.startsWith("@react-three/fiber")) {
|
||||||
|
return context.resolveRequest(
|
||||||
|
{
|
||||||
|
...context,
|
||||||
|
unstable_conditionNames: ["module"],
|
||||||
|
mainFields: ["module"],
|
||||||
|
},
|
||||||
|
moduleName,
|
||||||
|
platform,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context.resolveRequest(context, moduleName, platform);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Required Lib Files
|
||||||
|
|
||||||
|
Create these files in `src/lib/`:
|
||||||
|
|
||||||
|
### 1. make-webgpu-renderer.ts
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { NativeCanvas } from "react-native-wgpu";
|
||||||
|
import * as THREE from "three/webgpu";
|
||||||
|
|
||||||
|
export class ReactNativeCanvas {
|
||||||
|
constructor(private canvas: NativeCanvas) {}
|
||||||
|
|
||||||
|
get width() {
|
||||||
|
return this.canvas.width;
|
||||||
|
}
|
||||||
|
get height() {
|
||||||
|
return this.canvas.height;
|
||||||
|
}
|
||||||
|
set width(width: number) {
|
||||||
|
this.canvas.width = width;
|
||||||
|
}
|
||||||
|
set height(height: number) {
|
||||||
|
this.canvas.height = height;
|
||||||
|
}
|
||||||
|
get clientWidth() {
|
||||||
|
return this.canvas.width;
|
||||||
|
}
|
||||||
|
get clientHeight() {
|
||||||
|
return this.canvas.height;
|
||||||
|
}
|
||||||
|
set clientWidth(width: number) {
|
||||||
|
this.canvas.width = width;
|
||||||
|
}
|
||||||
|
set clientHeight(height: number) {
|
||||||
|
this.canvas.height = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(_type: string, _listener: EventListener) {}
|
||||||
|
removeEventListener(_type: string, _listener: EventListener) {}
|
||||||
|
dispatchEvent(_event: Event) {}
|
||||||
|
setPointerCapture() {}
|
||||||
|
releasePointerCapture() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeWebGPURenderer = (
|
||||||
|
context: GPUCanvasContext,
|
||||||
|
{ antialias = true }: { antialias?: boolean } = {},
|
||||||
|
) =>
|
||||||
|
new THREE.WebGPURenderer({
|
||||||
|
antialias,
|
||||||
|
// @ts-expect-error
|
||||||
|
canvas: new ReactNativeCanvas(context.canvas),
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. fiber-canvas.tsx
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import * as THREE from "three/webgpu";
|
||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import type { ReconcilerRoot, RootState } from "@react-three/fiber";
|
||||||
|
import { extend, createRoot, unmountComponentAtNode, events } from "@react-three/fiber";
|
||||||
|
import type { ViewProps } from "react-native";
|
||||||
|
import { PixelRatio } from "react-native";
|
||||||
|
import { Canvas, type CanvasRef } from "react-native-wgpu";
|
||||||
|
|
||||||
|
import { makeWebGPURenderer, ReactNativeCanvas } from "@/lib/make-webgpu-renderer";
|
||||||
|
|
||||||
|
// Extend THREE namespace for R3F - add all components you use
|
||||||
|
extend({
|
||||||
|
AmbientLight: THREE.AmbientLight,
|
||||||
|
DirectionalLight: THREE.DirectionalLight,
|
||||||
|
PointLight: THREE.PointLight,
|
||||||
|
SpotLight: THREE.SpotLight,
|
||||||
|
Mesh: THREE.Mesh,
|
||||||
|
Group: THREE.Group,
|
||||||
|
Points: THREE.Points,
|
||||||
|
BoxGeometry: THREE.BoxGeometry,
|
||||||
|
SphereGeometry: THREE.SphereGeometry,
|
||||||
|
CylinderGeometry: THREE.CylinderGeometry,
|
||||||
|
ConeGeometry: THREE.ConeGeometry,
|
||||||
|
DodecahedronGeometry: THREE.DodecahedronGeometry,
|
||||||
|
BufferGeometry: THREE.BufferGeometry,
|
||||||
|
BufferAttribute: THREE.BufferAttribute,
|
||||||
|
MeshStandardMaterial: THREE.MeshStandardMaterial,
|
||||||
|
MeshBasicMaterial: THREE.MeshBasicMaterial,
|
||||||
|
PointsMaterial: THREE.PointsMaterial,
|
||||||
|
PerspectiveCamera: THREE.PerspectiveCamera,
|
||||||
|
Scene: THREE.Scene,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface FiberCanvasProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
style?: ViewProps["style"];
|
||||||
|
camera?: THREE.PerspectiveCamera;
|
||||||
|
scene?: THREE.Scene;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FiberCanvas = ({ children, style, scene, camera }: FiberCanvasProps) => {
|
||||||
|
const root = useRef<ReconcilerRoot<OffscreenCanvas>>(null!);
|
||||||
|
const canvasRef = useRef<CanvasRef>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const context = canvasRef.current!.getContext("webgpu")!;
|
||||||
|
const renderer = makeWebGPURenderer(context);
|
||||||
|
|
||||||
|
// @ts-expect-error - ReactNativeCanvas wraps native canvas
|
||||||
|
const canvas = new ReactNativeCanvas(context.canvas) as HTMLCanvasElement;
|
||||||
|
canvas.width = canvas.clientWidth * PixelRatio.get();
|
||||||
|
canvas.height = canvas.clientHeight * PixelRatio.get();
|
||||||
|
const size = {
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: canvas.clientWidth,
|
||||||
|
height: canvas.clientHeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!root.current) {
|
||||||
|
root.current = createRoot(canvas);
|
||||||
|
}
|
||||||
|
root.current.configure({
|
||||||
|
size,
|
||||||
|
events,
|
||||||
|
scene,
|
||||||
|
camera,
|
||||||
|
gl: renderer,
|
||||||
|
frameloop: "always",
|
||||||
|
dpr: 1,
|
||||||
|
onCreated: async (state: RootState) => {
|
||||||
|
// @ts-expect-error - WebGPU renderer has init method
|
||||||
|
await state.gl.init();
|
||||||
|
const renderFrame = state.gl.render.bind(state.gl);
|
||||||
|
state.gl.render = (s: THREE.Scene, c: THREE.Camera) => {
|
||||||
|
renderFrame(s, c);
|
||||||
|
context?.present();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
root.current.render(children);
|
||||||
|
return () => {
|
||||||
|
if (canvas != null) {
|
||||||
|
unmountComponentAtNode(canvas!);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return <Canvas ref={canvasRef} style={style} />;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Basic 3D Scene
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import * as THREE from "three/webgpu";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
|
import { FiberCanvas } from "@/lib/fiber-canvas";
|
||||||
|
|
||||||
|
function RotatingBox() {
|
||||||
|
const ref = useRef<THREE.Mesh>(null!);
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
ref.current.rotation.x += delta;
|
||||||
|
ref.current.rotation.y += delta * 0.5;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh ref={ref}>
|
||||||
|
<boxGeometry args={[1, 1, 1]} />
|
||||||
|
<meshStandardMaterial color="hotpink" />
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Scene() {
|
||||||
|
const { camera } = useThree();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
camera.position.set(0, 2, 5);
|
||||||
|
camera.lookAt(0, 0, 0);
|
||||||
|
}, [camera]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ambientLight intensity={0.5} />
|
||||||
|
<directionalLight position={[10, 10, 5]} intensity={1} />
|
||||||
|
<RotatingBox />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<FiberCanvas style={{ flex: 1 }}>
|
||||||
|
<Scene />
|
||||||
|
</FiberCanvas>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lazy Loading (Recommended)
|
||||||
|
|
||||||
|
Use React.lazy to code-split Three.js for better loading:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React, { Suspense } from "react";
|
||||||
|
import { ActivityIndicator, View } from "react-native";
|
||||||
|
|
||||||
|
const Scene = React.lazy(() => import("@/components/scene"));
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Suspense fallback={<ActivityIndicator size="large" />}>
|
||||||
|
<Scene />
|
||||||
|
</Suspense>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Geometries
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Box
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[width, height, depth]} />
|
||||||
|
<meshStandardMaterial color="red" />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
// Sphere
|
||||||
|
<mesh>
|
||||||
|
<sphereGeometry args={[radius, widthSegments, heightSegments]} />
|
||||||
|
<meshStandardMaterial color="blue" />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
// Cylinder
|
||||||
|
<mesh>
|
||||||
|
<cylinderGeometry args={[radiusTop, radiusBottom, height, segments]} />
|
||||||
|
<meshStandardMaterial color="green" />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
// Cone
|
||||||
|
<mesh>
|
||||||
|
<coneGeometry args={[radius, height, segments]} />
|
||||||
|
<meshStandardMaterial color="yellow" />
|
||||||
|
</mesh>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lighting
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Ambient (uniform light everywhere)
|
||||||
|
<ambientLight intensity={0.5} />
|
||||||
|
|
||||||
|
// Directional (sun-like)
|
||||||
|
<directionalLight position={[10, 10, 5]} intensity={1} />
|
||||||
|
|
||||||
|
// Point (light bulb)
|
||||||
|
<pointLight position={[0, 5, 0]} intensity={2} distance={10} />
|
||||||
|
|
||||||
|
// Spot (flashlight)
|
||||||
|
<spotLight position={[0, 10, 0]} angle={0.3} penumbra={1} intensity={2} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Animation with useFrame
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import * as THREE from "three/webgpu";
|
||||||
|
|
||||||
|
function AnimatedMesh() {
|
||||||
|
const ref = useRef<THREE.Mesh>(null!);
|
||||||
|
|
||||||
|
// Runs every frame - delta is time since last frame
|
||||||
|
useFrame((state, delta) => {
|
||||||
|
// Rotate
|
||||||
|
ref.current.rotation.y += delta;
|
||||||
|
|
||||||
|
// Oscillate position
|
||||||
|
ref.current.position.y = Math.sin(state.clock.elapsedTime) * 2;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh ref={ref}>
|
||||||
|
<boxGeometry />
|
||||||
|
<meshStandardMaterial color="orange" />
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Particle Systems
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import * as THREE from "three/webgpu";
|
||||||
|
import { useRef, useEffect } from "react";
|
||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
|
||||||
|
function Particles({ count = 500 }) {
|
||||||
|
const ref = useRef<THREE.Points>(null!);
|
||||||
|
const positions = useRef<Float32Array>(new Float32Array(count * 3));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
positions.current[i * 3] = (Math.random() - 0.5) * 50;
|
||||||
|
positions.current[i * 3 + 1] = (Math.random() - 0.5) * 50;
|
||||||
|
positions.current[i * 3 + 2] = (Math.random() - 0.5) * 50;
|
||||||
|
}
|
||||||
|
}, [count]);
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
// Animate particles
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
positions.current[i * 3 + 1] -= delta * 2;
|
||||||
|
if (positions.current[i * 3 + 1] < -25) {
|
||||||
|
positions.current[i * 3 + 1] = 25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ref.current.geometry.attributes.position.needsUpdate = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<points ref={ref}>
|
||||||
|
<bufferGeometry>
|
||||||
|
<bufferAttribute attach="attributes-position" args={[positions.current, 3]} />
|
||||||
|
</bufferGeometry>
|
||||||
|
<pointsMaterial color="#ffffff" size={0.2} sizeAttenuation />
|
||||||
|
</points>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Touch Controls (Orbit)
|
||||||
|
|
||||||
|
See the full `orbit-controls.tsx` implementation in the lib files. Usage:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { FiberCanvas } from "@/lib/fiber-canvas";
|
||||||
|
import useControls from "@/lib/orbit-controls";
|
||||||
|
|
||||||
|
function Scene() {
|
||||||
|
const [OrbitControls, events] = useControls();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }} {...events}>
|
||||||
|
<FiberCanvas style={{ flex: 1 }}>
|
||||||
|
<OrbitControls />
|
||||||
|
{/* Your 3D content */}
|
||||||
|
</FiberCanvas>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Issues & Solutions
|
||||||
|
|
||||||
|
### 1. "X is not part of the THREE namespace"
|
||||||
|
|
||||||
|
**Problem:** Error like `AmbientLight is not part of the THREE namespace`
|
||||||
|
|
||||||
|
**Solution:** Add the missing component to the `extend()` call in fiber-canvas.tsx:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
extend({
|
||||||
|
AmbientLight: THREE.AmbientLight,
|
||||||
|
// Add other missing components...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. TypeScript Errors with Three.js
|
||||||
|
|
||||||
|
**Problem:** Type mismatches between three.js and R3F
|
||||||
|
|
||||||
|
**Solution:** Use `@ts-expect-error` comments where needed:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// @ts-expect-error - WebGPU renderer types don't match
|
||||||
|
await state.gl.init();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Blank Screen
|
||||||
|
|
||||||
|
**Problem:** Canvas renders but nothing visible
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
|
||||||
|
1. Ensure camera is positioned correctly and looking at scene
|
||||||
|
2. Add lighting (objects are black without light)
|
||||||
|
3. Check that `extend()` includes all components used
|
||||||
|
|
||||||
|
### 4. Performance Issues
|
||||||
|
|
||||||
|
**Problem:** Low frame rate or stuttering
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
|
||||||
|
- Reduce polygon count in geometries
|
||||||
|
- Use `useMemo` for static data
|
||||||
|
- Limit particle count
|
||||||
|
- Use `instancedMesh` for many identical objects
|
||||||
|
|
||||||
|
### 5. Peer Dependency Errors
|
||||||
|
|
||||||
|
**Problem:** npm install fails with ERESOLVE
|
||||||
|
|
||||||
|
**Solution:** Use `--legacy-peer-deps`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install <packages> --legacy-peer-deps
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
WebGPU requires a custom build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo prebuild
|
||||||
|
npx expo run:ios
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** WebGPU does NOT work in Expo Go.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/
|
||||||
|
│ └── index.tsx # Entry point with lazy loading
|
||||||
|
├── components/
|
||||||
|
│ ├── scene.tsx # Main 3D scene
|
||||||
|
│ └── game.tsx # Game logic
|
||||||
|
└── lib/
|
||||||
|
├── fiber-canvas.tsx # R3F canvas wrapper
|
||||||
|
├── make-webgpu-renderer.ts # WebGPU renderer
|
||||||
|
└── orbit-controls.tsx # Touch controls
|
||||||
|
```
|
||||||
|
|
||||||
|
## Decision Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
Need 3D graphics?
|
||||||
|
├── Simple shapes → mesh + geometry + material
|
||||||
|
├── Animated objects → useFrame + refs
|
||||||
|
├── Many objects → instancedMesh
|
||||||
|
├── Particles → Points + BufferGeometry
|
||||||
|
│
|
||||||
|
Need interaction?
|
||||||
|
├── Orbit camera → useControls hook
|
||||||
|
├── Touch objects → onClick on mesh
|
||||||
|
├── Gestures → react-native-gesture-handler
|
||||||
|
│
|
||||||
|
Performance critical?
|
||||||
|
├── Static geometry → useMemo
|
||||||
|
├── Many instances → InstancedMesh
|
||||||
|
└── Complex scenes → LOD (Level of Detail)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Complete Game Scene
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import * as THREE from "three/webgpu";
|
||||||
|
import { View, Text, Pressable } from "react-native";
|
||||||
|
import { useRef, useState, useCallback } from "react";
|
||||||
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
|
import { FiberCanvas } from "@/lib/fiber-canvas";
|
||||||
|
|
||||||
|
function Player({ position }: { position: THREE.Vector3 }) {
|
||||||
|
const ref = useRef<THREE.Mesh>(null!);
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
ref.current.position.copy(position);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh ref={ref}>
|
||||||
|
<coneGeometry args={[0.5, 1, 8]} />
|
||||||
|
<meshStandardMaterial color="#00ffff" />
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GameScene({ playerX }: { playerX: number }) {
|
||||||
|
const { camera } = useThree();
|
||||||
|
const playerPos = useRef(new THREE.Vector3(0, 0, 0));
|
||||||
|
|
||||||
|
playerPos.current.x = playerX;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
camera.position.set(0, 10, 15);
|
||||||
|
camera.lookAt(0, 0, 0);
|
||||||
|
}, [camera]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ambientLight intensity={0.5} />
|
||||||
|
<directionalLight position={[5, 10, 5]} />
|
||||||
|
<Player position={playerPos.current} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Game() {
|
||||||
|
const [playerX, setPlayerX] = useState(0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "#000" }}>
|
||||||
|
<FiberCanvas style={{ flex: 1 }}>
|
||||||
|
<GameScene playerX={playerX} />
|
||||||
|
</FiberCanvas>
|
||||||
|
|
||||||
|
<View style={{ position: "absolute", bottom: 40, flexDirection: "row" }}>
|
||||||
|
<Pressable onPress={() => setPlayerX((x) => x - 1)}>
|
||||||
|
<Text style={{ color: "#fff", fontSize: 32 }}>◀</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={() => setPlayerX((x) => x + 1)}>
|
||||||
|
<Text style={{ color: "#fff", fontSize: 32 }}>▶</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
161
.agents/skills/building-native-ui/references/zoom-transitions.md
Normal file
161
.agents/skills/building-native-ui/references/zoom-transitions.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# Apple Zoom Transitions
|
||||||
|
|
||||||
|
Fluid zoom transitions for navigating between screens. iOS 18+, Expo SDK 55+, Stack navigator only.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Link } from "expo-router";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Basic Zoom
|
||||||
|
|
||||||
|
Use `withAppleZoom` on `Link.Trigger` to zoom the entire trigger element into the destination screen:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Link href="/photo" asChild>
|
||||||
|
<Link.Trigger withAppleZoom>
|
||||||
|
<Pressable>
|
||||||
|
<Image
|
||||||
|
source={{ uri: "https://example.com/thumb.jpg" }}
|
||||||
|
style={{ width: 120, height: 120, borderRadius: 12 }}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</Link.Trigger>
|
||||||
|
</Link>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Targeted Zoom with `Link.AppleZoom`
|
||||||
|
|
||||||
|
Wrap only the element that should animate. Siblings outside `Link.AppleZoom` are not part of the transition:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Link href="/photo" asChild>
|
||||||
|
<Link.Trigger>
|
||||||
|
<Pressable style={{ alignItems: "center" }}>
|
||||||
|
<Link.AppleZoom>
|
||||||
|
<Image
|
||||||
|
source={{ uri: "https://example.com/thumb.jpg" }}
|
||||||
|
style={{ width: 200, aspectRatio: 4 / 3 }}
|
||||||
|
/>
|
||||||
|
</Link.AppleZoom>
|
||||||
|
<Text>Caption text (not zoomed)</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Link.Trigger>
|
||||||
|
</Link>
|
||||||
|
```
|
||||||
|
|
||||||
|
`Link.AppleZoom` accepts only a single child element.
|
||||||
|
|
||||||
|
## Destination Target
|
||||||
|
|
||||||
|
Use `Link.AppleZoomTarget` on the destination screen to align the zoom animation to a specific element:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Destination screen (e.g., app/photo.tsx)
|
||||||
|
import { Link } from "expo-router";
|
||||||
|
|
||||||
|
export default function PhotoScreen() {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Link.AppleZoomTarget>
|
||||||
|
<Image
|
||||||
|
source={{ uri: "https://example.com/full.jpg" }}
|
||||||
|
style={{ width: "100%", aspectRatio: 4 / 3 }}
|
||||||
|
/>
|
||||||
|
</Link.AppleZoomTarget>
|
||||||
|
<Text>Photo details below</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Without a target, the zoom animates to fill the entire destination screen.
|
||||||
|
|
||||||
|
## Custom Alignment Rectangle
|
||||||
|
|
||||||
|
For manual control over where the zoom lands on the destination, use `alignmentRect` instead of `Link.AppleZoomTarget`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Link.AppleZoom alignmentRect={{ x: 0, y: 0, width: 200, height: 300 }}>
|
||||||
|
<Image source={{ uri: "https://example.com/thumb.jpg" }} />
|
||||||
|
</Link.AppleZoom>
|
||||||
|
```
|
||||||
|
|
||||||
|
Coordinates are in the destination screen's coordinate space. Prefer `Link.AppleZoomTarget` when possible — use `alignmentRect` only when the target element isn't available as a React component.
|
||||||
|
|
||||||
|
## Controlling Dismissal
|
||||||
|
|
||||||
|
Zoom screens support interactive dismissal gestures by default (pinch, swipe down when scrolled to top, swipe from leading edge). Use `usePreventZoomTransitionDismissal` on the destination screen to control this.
|
||||||
|
|
||||||
|
### Disable all dismissal gestures
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { usePreventZoomTransitionDismissal } from "expo-router";
|
||||||
|
|
||||||
|
export default function PhotoScreen() {
|
||||||
|
usePreventZoomTransitionDismissal();
|
||||||
|
return <Image source={{ uri: "https://example.com/full.jpg" }} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restrict dismissal to a specific area
|
||||||
|
|
||||||
|
Use `unstable_dismissalBoundsRect` to prevent conflicts with scrollable content:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
usePreventZoomTransitionDismissal({
|
||||||
|
unstable_dismissalBoundsRect: {
|
||||||
|
minX: 0,
|
||||||
|
minY: 0,
|
||||||
|
maxX: 300,
|
||||||
|
maxY: 300,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
This is useful when the destination contains a zoomable scroll view — the system gives that scroll view precedence over the dismiss gesture.
|
||||||
|
|
||||||
|
## Combining with Link.Preview
|
||||||
|
|
||||||
|
Zoom transitions work alongside long-press previews:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Link href="/photo" asChild>
|
||||||
|
<Link.Trigger withAppleZoom>
|
||||||
|
<Pressable>
|
||||||
|
<Image
|
||||||
|
source={{ uri: "https://example.com/thumb.jpg" }}
|
||||||
|
style={{ width: 120, height: 120 }}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</Link.Trigger>
|
||||||
|
<Link.Preview />
|
||||||
|
</Link>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
**Good use cases:**
|
||||||
|
|
||||||
|
- Thumbnail → full image (gallery, profile photos)
|
||||||
|
- Card → detail screen with similar visual content
|
||||||
|
- Source and destination with similar aspect ratios
|
||||||
|
|
||||||
|
**Avoid:**
|
||||||
|
|
||||||
|
- Skinny full-width list rows as zoom sources — the transition looks unnatural
|
||||||
|
- Mismatched aspect ratios between source and destination without `alignmentRect`
|
||||||
|
- Using zoom with sheets or popovers — only works in Stack navigator
|
||||||
|
- Hiding the navigation bar — known issues with header visibility during transitions
|
||||||
|
|
||||||
|
**Tips:**
|
||||||
|
|
||||||
|
- Always provide a close or back button — dismissal gestures are not discoverable
|
||||||
|
- If the destination has a zoomable scroll view, use `unstable_dismissalBoundsRect` to avoid gesture conflicts
|
||||||
|
- Source view doesn't need to match the tap target — only the `Link.AppleZoom` wrapped element animates
|
||||||
|
- When source is unavailable (e.g., scrolled off screen), the transition zooms from the center of the screen
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Expo Router Zoom Transitions: https://docs.expo.dev/router/advanced/zoom-transition/
|
||||||
|
- Link.AppleZoom API: https://docs.expo.dev/versions/v55.0.0/sdk/router/#linkapplezoom
|
||||||
|
- Apple UIKit Fluid Transitions: https://developer.apple.com/documentation/uikit/enhancing-your-app-with-fluid-transitions
|
||||||
92
.agents/skills/expo-cicd-workflows/SKILL.md
Normal file
92
.agents/skills/expo-cicd-workflows/SKILL.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
---
|
||||||
|
name: expo-cicd-workflows
|
||||||
|
description: Helps understand and write EAS workflow YAML files for Expo projects. Use this skill when the user asks about CI/CD or workflows in an Expo or EAS context, mentions .eas/workflows/, or wants help with EAS build pipelines or deployment automation.
|
||||||
|
allowed-tools: "Read,Write,Bash(node:*)"
|
||||||
|
version: 1.0.0
|
||||||
|
license: MIT License
|
||||||
|
---
|
||||||
|
|
||||||
|
# EAS Workflows Skill
|
||||||
|
|
||||||
|
Help developers write and edit EAS CI/CD workflow YAML files.
|
||||||
|
|
||||||
|
## Reference Documentation
|
||||||
|
|
||||||
|
Fetch these resources before generating or validating workflow files. Use the fetch script (implemented using Node.js) in this skill's `scripts/` directory; it caches responses using ETags for efficiency:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Fetch resources
|
||||||
|
node {baseDir}/scripts/fetch.js <url>
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **JSON Schema** — https://api.expo.dev/v2/workflows/schema
|
||||||
|
- It is NECESSARY to fetch this schema
|
||||||
|
- Source of truth for validation
|
||||||
|
- All job types and their required/optional parameters
|
||||||
|
- Trigger types and configurations
|
||||||
|
- Runner types, VM images, and all enums
|
||||||
|
|
||||||
|
2. **Syntax Documentation** — https://raw.githubusercontent.com/expo/expo/refs/heads/main/docs/pages/eas/workflows/syntax.mdx
|
||||||
|
- Overview of workflow YAML syntax
|
||||||
|
- Examples and English explanations
|
||||||
|
- Expression syntax and contexts
|
||||||
|
|
||||||
|
3. **Pre-packaged Jobs** — https://raw.githubusercontent.com/expo/expo/refs/heads/main/docs/pages/eas/workflows/pre-packaged-jobs.mdx
|
||||||
|
- Documentation for supported pre-packaged job types
|
||||||
|
- Job-specific parameters and outputs
|
||||||
|
|
||||||
|
Do not rely on memorized values; these resources evolve as new features are added.
|
||||||
|
|
||||||
|
## Workflow File Location
|
||||||
|
|
||||||
|
Workflows live in `.eas/workflows/*.yml` (or `.yaml`).
|
||||||
|
|
||||||
|
## Top-Level Structure
|
||||||
|
|
||||||
|
A workflow file has these top-level keys:
|
||||||
|
|
||||||
|
- `name` — Display name for the workflow
|
||||||
|
- `on` — Triggers that start the workflow (at least one required)
|
||||||
|
- `jobs` — Job definitions (required)
|
||||||
|
- `defaults` — Shared defaults for all jobs
|
||||||
|
- `concurrency` — Control parallel workflow runs
|
||||||
|
|
||||||
|
Consult the schema for the full specification of each section.
|
||||||
|
|
||||||
|
## Expressions
|
||||||
|
|
||||||
|
Use `${{ }}` syntax for dynamic values. The schema defines available contexts:
|
||||||
|
|
||||||
|
- `github.*` — GitHub repository and event information
|
||||||
|
- `inputs.*` — Values from `workflow_dispatch` inputs
|
||||||
|
- `needs.*` — Outputs and status from dependent jobs
|
||||||
|
- `jobs.*` — Job outputs (alternative syntax)
|
||||||
|
- `steps.*` — Step outputs within custom jobs
|
||||||
|
- `workflow.*` — Workflow metadata
|
||||||
|
|
||||||
|
## Generating Workflows
|
||||||
|
|
||||||
|
When generating or editing workflows:
|
||||||
|
|
||||||
|
1. Fetch the schema to get current job types, parameters, and allowed values
|
||||||
|
2. Validate that required fields are present for each job type
|
||||||
|
3. Verify job references in `needs` and `after` exist in the workflow
|
||||||
|
4. Check that expressions reference valid contexts and outputs
|
||||||
|
5. Ensure `if` conditions respect the schema's length constraints
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
After generating or editing a workflow file, validate it against the schema:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Install dependencies if missing
|
||||||
|
[ -d "{baseDir}/scripts/node_modules" ] || npm install --prefix {baseDir}/scripts
|
||||||
|
|
||||||
|
node {baseDir}/scripts/validate.js <workflow.yml> [workflow2.yml ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
The validator fetches the latest schema and checks the YAML structure. Fix any reported errors before considering the workflow complete.
|
||||||
|
|
||||||
|
## Answering Questions
|
||||||
|
|
||||||
|
When users ask about available options (job types, triggers, runner types, etc.), fetch the schema and derive the answer from it rather than relying on potentially outdated information.
|
||||||
109
.agents/skills/expo-cicd-workflows/scripts/fetch.js
Normal file
109
.agents/skills/expo-cicd-workflows/scripts/fetch.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
const CACHE_DIRECTORY = resolve(import.meta.dirname, ".cache");
|
||||||
|
const DEFAULT_TTL_SECONDS = 15 * 60; // 15 minutes
|
||||||
|
|
||||||
|
export async function fetchCached(url) {
|
||||||
|
await mkdir(CACHE_DIRECTORY, { recursive: true });
|
||||||
|
|
||||||
|
const cacheFile = resolve(CACHE_DIRECTORY, hashUrl(url) + ".json");
|
||||||
|
const cached = await loadCacheEntry(cacheFile);
|
||||||
|
if (cached && cached.expires > Math.floor(Date.now() / 1000)) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make request, with conditional If-None-Match if we have an ETag.
|
||||||
|
// Cache-Control: max-age=0 overrides Node's default 'no-cache' to allow 304 responses.
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "max-age=0",
|
||||||
|
...(cached?.etag && { "If-None-Match": cached.etag }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 304 && cached) {
|
||||||
|
// Refresh expiration and return cached data
|
||||||
|
const entry = { ...cached, expires: getExpires(response.headers) };
|
||||||
|
await saveCacheEntry(cacheFile, entry);
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const etag = response.headers.get("etag");
|
||||||
|
const data = await response.text();
|
||||||
|
const expires = getExpires(response.headers);
|
||||||
|
|
||||||
|
await saveCacheEntry(cacheFile, { url, etag, expires, data });
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashUrl(url) {
|
||||||
|
return createHash("sha256").update(url).digest("hex").slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCacheEntry(cacheFile) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await readFile(cacheFile, "utf-8"));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCacheEntry(cacheFile, entry) {
|
||||||
|
await writeFile(cacheFile, JSON.stringify(entry, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExpires(headers) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
// Prefer Cache-Control: max-age
|
||||||
|
const maxAgeSeconds = parseMaxAge(headers.get("cache-control"));
|
||||||
|
if (maxAgeSeconds != null) {
|
||||||
|
return now + maxAgeSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to Expires header
|
||||||
|
const expires = headers.get("expires");
|
||||||
|
if (expires) {
|
||||||
|
const expiresTime = Date.parse(expires);
|
||||||
|
if (!Number.isNaN(expiresTime)) {
|
||||||
|
return Math.floor(expiresTime / 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default TTL
|
||||||
|
return now + DEFAULT_TTL_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMaxAge(cacheControl) {
|
||||||
|
if (!cacheControl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const match = cacheControl.match(/max-age=(\d+)/i);
|
||||||
|
return match ? parseInt(match[1], 10) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
const url = process.argv[2];
|
||||||
|
|
||||||
|
if (!url || url === "--help" || url === "-h") {
|
||||||
|
console.log(`Usage: fetch <url>
|
||||||
|
|
||||||
|
Fetches a URL with HTTP caching (ETags + Cache-Control/Expires).
|
||||||
|
Default TTL: ${DEFAULT_TTL_SECONDS / 60} minutes.
|
||||||
|
Cache is stored in: ${CACHE_DIRECTORY}/`);
|
||||||
|
process.exit(url ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await fetchCached(url);
|
||||||
|
console.log(data);
|
||||||
|
}
|
||||||
11
.agents/skills/expo-cicd-workflows/scripts/package.json
Normal file
11
.agents/skills/expo-cicd-workflows/scripts/package.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@expo/cicd-workflows-skill",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"ajv": "^8.17.1",
|
||||||
|
"ajv-formats": "^3.0.1",
|
||||||
|
"js-yaml": "^4.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
84
.agents/skills/expo-cicd-workflows/scripts/validate.js
Normal file
84
.agents/skills/expo-cicd-workflows/scripts/validate.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
import addFormats from "ajv-formats";
|
||||||
|
import Ajv2020 from "ajv/dist/2020.js";
|
||||||
|
import yaml from "js-yaml";
|
||||||
|
|
||||||
|
import { fetchCached } from "./fetch.js";
|
||||||
|
|
||||||
|
const SCHEMA_URL = "https://api.expo.dev/v2/workflows/schema";
|
||||||
|
|
||||||
|
async function fetchSchema() {
|
||||||
|
const data = await fetchCached(SCHEMA_URL);
|
||||||
|
const body = JSON.parse(data);
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createValidator(schema) {
|
||||||
|
const ajv = new Ajv2020({ allErrors: true, strict: true });
|
||||||
|
addFormats(ajv);
|
||||||
|
return ajv.compile(schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateFile(validator, filePath) {
|
||||||
|
const content = await readFile(filePath, "utf-8");
|
||||||
|
|
||||||
|
let doc;
|
||||||
|
try {
|
||||||
|
doc = yaml.load(content);
|
||||||
|
} catch (e) {
|
||||||
|
return { valid: false, error: `YAML parse error: ${e.message}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = validator(doc);
|
||||||
|
if (!valid) {
|
||||||
|
return { valid: false, error: formatErrors(validator.errors) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatErrors(errors) {
|
||||||
|
return errors
|
||||||
|
.map((error) => {
|
||||||
|
const path = error.instancePath || "(root)";
|
||||||
|
const allowed = error.params?.allowedValues?.join(", ");
|
||||||
|
return ` ${path}: ${error.message}${allowed ? ` (allowed: ${allowed})` : ""}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const files = args.filter((a) => !a.startsWith("-"));
|
||||||
|
|
||||||
|
if (files.length === 0 || args.includes("--help") || args.includes("-h")) {
|
||||||
|
console.log(`Usage: validate <workflow.yml> [workflow2.yml ...]
|
||||||
|
|
||||||
|
Validates EAS workflow YAML files against the official schema.`);
|
||||||
|
process.exit(files.length === 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = await fetchSchema();
|
||||||
|
const validator = createValidator(schema);
|
||||||
|
|
||||||
|
let hasErrors = false;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = resolve(process.cwd(), file);
|
||||||
|
const result = await validateFile(validator, filePath);
|
||||||
|
|
||||||
|
if (result.valid) {
|
||||||
|
console.log(`✓ ${file}`);
|
||||||
|
} else {
|
||||||
|
console.error(`✗ ${file}\n${result.error}`);
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(hasErrors ? 1 : 0);
|
||||||
|
}
|
||||||
190
.agents/skills/expo-deployment/SKILL.md
Normal file
190
.agents/skills/expo-deployment/SKILL.md
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
---
|
||||||
|
name: expo-deployment
|
||||||
|
description: Deploying Expo apps to iOS App Store, Android Play Store, web hosting, and API routes
|
||||||
|
version: 1.0.0
|
||||||
|
license: MIT
|
||||||
|
---
|
||||||
|
|
||||||
|
# Deployment
|
||||||
|
|
||||||
|
This skill covers deploying Expo applications across all platforms using EAS (Expo Application Services).
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
Consult these resources as needed:
|
||||||
|
|
||||||
|
- ./references/workflows.md -- CI/CD workflows for automated deployments and PR previews
|
||||||
|
- ./references/testflight.md -- Submitting iOS builds to TestFlight for beta testing
|
||||||
|
- ./references/app-store-metadata.md -- Managing App Store metadata and ASO optimization
|
||||||
|
- ./references/play-store.md -- Submitting Android builds to Google Play Store
|
||||||
|
- ./references/ios-app-store.md -- iOS App Store submission and review process
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Install EAS CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g eas-cli
|
||||||
|
eas login
|
||||||
|
```
|
||||||
|
|
||||||
|
### Initialize EAS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx eas-cli@latest init
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates `eas.json` with build profiles.
|
||||||
|
|
||||||
|
## Build Commands
|
||||||
|
|
||||||
|
### Production Builds
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# iOS App Store build
|
||||||
|
npx eas-cli@latest build -p ios --profile production
|
||||||
|
|
||||||
|
# Android Play Store build
|
||||||
|
npx eas-cli@latest build -p android --profile production
|
||||||
|
|
||||||
|
# Both platforms
|
||||||
|
npx eas-cli@latest build --profile production
|
||||||
|
```
|
||||||
|
|
||||||
|
### Submit to Stores
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# iOS: Build and submit to App Store Connect
|
||||||
|
npx eas-cli@latest build -p ios --profile production --submit
|
||||||
|
|
||||||
|
# Android: Build and submit to Play Store
|
||||||
|
npx eas-cli@latest build -p android --profile production --submit
|
||||||
|
|
||||||
|
# Shortcut for iOS TestFlight
|
||||||
|
npx testflight
|
||||||
|
```
|
||||||
|
|
||||||
|
## Web Deployment
|
||||||
|
|
||||||
|
Deploy web apps using EAS Hosting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Deploy to production
|
||||||
|
npx expo export -p web
|
||||||
|
npx eas-cli@latest deploy --prod
|
||||||
|
|
||||||
|
# Deploy PR preview
|
||||||
|
npx eas-cli@latest deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## EAS Configuration
|
||||||
|
|
||||||
|
Standard `eas.json` for production deployments:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 16.0.1",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"production": {
|
||||||
|
"autoIncrement": true,
|
||||||
|
"ios": {
|
||||||
|
"resourceClass": "m-medium"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {
|
||||||
|
"ios": {
|
||||||
|
"appleId": "your@email.com",
|
||||||
|
"ascAppId": "1234567890"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"serviceAccountKeyPath": "./google-service-account.json",
|
||||||
|
"track": "internal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Platform-Specific Guides
|
||||||
|
|
||||||
|
### iOS
|
||||||
|
|
||||||
|
- Use `npx testflight` for quick TestFlight submissions
|
||||||
|
- Configure Apple credentials via `eas credentials`
|
||||||
|
- See ./reference/testflight.md for credential setup
|
||||||
|
- See ./reference/ios-app-store.md for App Store submission
|
||||||
|
|
||||||
|
### Android
|
||||||
|
|
||||||
|
- Set up Google Play Console service account
|
||||||
|
- Configure tracks: internal → closed → open → production
|
||||||
|
- See ./reference/play-store.md for detailed setup
|
||||||
|
|
||||||
|
### Web
|
||||||
|
|
||||||
|
- EAS Hosting provides preview URLs for PRs
|
||||||
|
- Production deploys to your custom domain
|
||||||
|
- See ./reference/workflows.md for CI/CD automation
|
||||||
|
|
||||||
|
## Automated Deployments
|
||||||
|
|
||||||
|
Use EAS Workflows for CI/CD:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .eas/workflows/release.yml
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-ios:
|
||||||
|
type: build
|
||||||
|
params:
|
||||||
|
platform: ios
|
||||||
|
profile: production
|
||||||
|
|
||||||
|
submit-ios:
|
||||||
|
type: submit
|
||||||
|
needs: [build-ios]
|
||||||
|
params:
|
||||||
|
platform: ios
|
||||||
|
profile: production
|
||||||
|
```
|
||||||
|
|
||||||
|
See ./reference/workflows.md for more workflow examples.
|
||||||
|
|
||||||
|
## Version Management
|
||||||
|
|
||||||
|
EAS manages version numbers automatically with `appVersionSource: "remote"`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check current versions
|
||||||
|
eas build:version:get
|
||||||
|
|
||||||
|
# Manually set version
|
||||||
|
eas build:version:set -p ios --build-number 42
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List recent builds
|
||||||
|
eas build:list
|
||||||
|
|
||||||
|
# Check build status
|
||||||
|
eas build:view
|
||||||
|
|
||||||
|
# View submission status
|
||||||
|
eas submit:list
|
||||||
|
```
|
||||||
477
.agents/skills/expo-deployment/references/app-store-metadata.md
Normal file
477
.agents/skills/expo-deployment/references/app-store-metadata.md
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
# App Store Metadata
|
||||||
|
|
||||||
|
Manage App Store metadata and optimize for ASO using EAS Metadata.
|
||||||
|
|
||||||
|
## What is EAS Metadata?
|
||||||
|
|
||||||
|
EAS Metadata automates App Store presence management from the command line using a `store.config.json` file instead of manually filling forms in App Store Connect. It includes built-in validation to catch common rejection pitfalls.
|
||||||
|
|
||||||
|
**Current Status:** Preview, Apple App Store only.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Pull Existing Metadata
|
||||||
|
|
||||||
|
If your app is already published, pull current metadata:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eas metadata:pull
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates `store.config.json` with your current App Store configuration.
|
||||||
|
|
||||||
|
### Push Metadata Updates
|
||||||
|
|
||||||
|
After editing your config, push changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eas metadata:push
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** You must submit a binary via `eas submit` before pushing metadata for new apps.
|
||||||
|
|
||||||
|
## Configuration File
|
||||||
|
|
||||||
|
Create `store.config.json` at your project root:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"configVersion": 0,
|
||||||
|
"apple": {
|
||||||
|
"copyright": "2025 Your Company",
|
||||||
|
"categories": ["UTILITIES", "PRODUCTIVITY"],
|
||||||
|
"info": {
|
||||||
|
"en-US": {
|
||||||
|
"title": "App Name",
|
||||||
|
"subtitle": "Your compelling tagline",
|
||||||
|
"description": "Full app description...",
|
||||||
|
"keywords": ["keyword1", "keyword2", "keyword3"],
|
||||||
|
"releaseNotes": "What's new in this version...",
|
||||||
|
"promoText": "Limited time offer!",
|
||||||
|
"privacyPolicyUrl": "https://example.com/privacy",
|
||||||
|
"supportUrl": "https://example.com/support",
|
||||||
|
"marketingUrl": "https://example.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"advisory": {
|
||||||
|
"alcoholTobaccoOrDrugUseOrReferences": "NONE",
|
||||||
|
"gamblingSimulated": "NONE",
|
||||||
|
"medicalOrTreatmentInformation": "NONE",
|
||||||
|
"profanityOrCrudeHumor": "NONE",
|
||||||
|
"sexualContentGraphicAndNudity": "NONE",
|
||||||
|
"sexualContentOrNudity": "NONE",
|
||||||
|
"horrorOrFearThemes": "NONE",
|
||||||
|
"matureOrSuggestiveThemes": "NONE",
|
||||||
|
"violenceCartoonOrFantasy": "NONE",
|
||||||
|
"violenceRealistic": "NONE",
|
||||||
|
"violenceRealisticProlongedGraphicOrSadistic": "NONE",
|
||||||
|
"contests": "NONE",
|
||||||
|
"gambling": false,
|
||||||
|
"unrestrictedWebAccess": false,
|
||||||
|
"seventeenPlus": false
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"automaticRelease": true,
|
||||||
|
"phasedRelease": true
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"firstName": "John",
|
||||||
|
"lastName": "Doe",
|
||||||
|
"email": "review@example.com",
|
||||||
|
"phone": "+1 555-123-4567",
|
||||||
|
"notes": "Demo account: test@example.com / password123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## App Store Optimization (ASO)
|
||||||
|
|
||||||
|
### Title Optimization (30 characters max)
|
||||||
|
|
||||||
|
The title is the most important ranking factor. Include your brand name and 1-2 strongest keywords.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Budgetly - Money Tracker"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Best Practices:**
|
||||||
|
|
||||||
|
- Brand name first for recognition
|
||||||
|
- Include highest-volume keyword
|
||||||
|
- Avoid generic words like "app" or "the"
|
||||||
|
- Title keywords boost rankings by ~10%
|
||||||
|
|
||||||
|
### Subtitle Optimization (30 characters max)
|
||||||
|
|
||||||
|
The subtitle appears below your title in search results. Use it for your unique value proposition.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subtitle": "Smart Expense & Budget Planner"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Best Practices:**
|
||||||
|
|
||||||
|
- Don't duplicate keywords from title (Apple counts each word once)
|
||||||
|
- Highlight your main differentiator
|
||||||
|
- Include secondary high-value keywords
|
||||||
|
- Focus on benefits, not features
|
||||||
|
|
||||||
|
### Keywords Field (100 characters max)
|
||||||
|
|
||||||
|
Hidden from users but crucial for discoverability. Use comma-separated keywords without spaces after commas.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"keywords": [
|
||||||
|
"finance,budget,expense,money,tracker,savings,bills,income,spending,wallet,personal,weekly,monthly"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Best Practices:**
|
||||||
|
|
||||||
|
- Use all 100 characters
|
||||||
|
- Separate with commas only (no spaces)
|
||||||
|
- No duplicates from title/subtitle
|
||||||
|
- Include singular forms (Apple handles plurals)
|
||||||
|
- Add synonyms and alternate spellings
|
||||||
|
- Include competitor brand names (carefully)
|
||||||
|
- Use digits instead of spelled numbers ("5" not "five")
|
||||||
|
- Skip articles and prepositions
|
||||||
|
|
||||||
|
### Description Optimization
|
||||||
|
|
||||||
|
The iOS description is NOT indexed for search but critical for conversion. Focus on convincing users to download.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"description": "Take control of your finances with Budgetly, the intuitive money management app trusted by over 1 million users.\n\nKEY FEATURES:\n• Smart budget tracking - Set limits and watch your progress\n• Expense categorization - Know exactly where your money goes\n• Bill reminders - Never miss a payment\n• Beautiful charts - Visualize your financial health\n• Bank sync - Connect 10,000+ institutions\n• Cloud backup - Your data, always safe\n\nWHY BUDGETLY?\nUnlike complex spreadsheets or basic calculators, Budgetly learns your spending habits and provides personalized insights. Our users save an average of $300/month within 3 months.\n\nPRIVACY FIRST\nYour financial data is encrypted end-to-end. We never sell your information.\n\nDownload Budgetly today and start your journey to financial freedom!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Best Practices:**
|
||||||
|
|
||||||
|
- Front-load the first 3 lines (visible before "more")
|
||||||
|
- Use bullet points for features
|
||||||
|
- Include social proof (user counts, ratings, awards)
|
||||||
|
- Add a clear call-to-action
|
||||||
|
- Mention privacy/security for sensitive apps
|
||||||
|
- Update with each release
|
||||||
|
|
||||||
|
### Release Notes
|
||||||
|
|
||||||
|
Shown to existing users deciding whether to update.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"releaseNotes": "Version 2.5 brings exciting improvements:\n\n• NEW: Dark mode support\n• NEW: Widget for home screen\n• IMPROVED: 50% faster sync\n• FIXED: Notification timing issues\n\nLove Budgetly? Please leave a review!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Promo Text (170 characters max)
|
||||||
|
|
||||||
|
Appears above description; can be updated without new binary. Great for time-sensitive promotions.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"promoText": "🎉 New Year Special: Premium features free for 30 days! Start 2025 with better finances."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Categories
|
||||||
|
|
||||||
|
Primary category is most important for browsing and rankings.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"categories": ["FINANCE", "PRODUCTIVITY"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available Categories:**
|
||||||
|
|
||||||
|
- BOOKS, BUSINESS, DEVELOPER_TOOLS, EDUCATION
|
||||||
|
- ENTERTAINMENT, FINANCE, FOOD_AND_DRINK
|
||||||
|
- GAMES (with subcategories), GRAPHICS_AND_DESIGN
|
||||||
|
- HEALTH_AND_FITNESS, KIDS (age-gated)
|
||||||
|
- LIFESTYLE, MAGAZINES_AND_NEWSPAPERS
|
||||||
|
- MEDICAL, MUSIC, NAVIGATION, NEWS
|
||||||
|
- PHOTO_AND_VIDEO, PRODUCTIVITY, REFERENCE
|
||||||
|
- SHOPPING, SOCIAL_NETWORKING, SPORTS
|
||||||
|
- STICKERS (with subcategories), TRAVEL
|
||||||
|
- UTILITIES, WEATHER
|
||||||
|
|
||||||
|
## Localization
|
||||||
|
|
||||||
|
Localize metadata for each target market. Keywords should be researched per locale—direct translations often miss regional search terms.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"en-US": {
|
||||||
|
"title": "Budgetly - Money Tracker",
|
||||||
|
"subtitle": "Smart Expense Planner",
|
||||||
|
"keywords": ["budget,finance,money,expense,tracker"]
|
||||||
|
},
|
||||||
|
"es-ES": {
|
||||||
|
"title": "Budgetly - Control de Gastos",
|
||||||
|
"subtitle": "Planificador de Presupuesto",
|
||||||
|
"keywords": ["presupuesto,finanzas,dinero,gastos,ahorro"]
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"title": "Budgetly - 家計簿アプリ",
|
||||||
|
"subtitle": "簡単支出管理",
|
||||||
|
"keywords": ["家計簿,支出,予算,節約,お金"]
|
||||||
|
},
|
||||||
|
"de-DE": {
|
||||||
|
"title": "Budgetly - Haushaltsbuch",
|
||||||
|
"subtitle": "Ausgaben Verwalten",
|
||||||
|
"keywords": ["budget,finanzen,geld,ausgaben,sparen"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Supported Locales:**
|
||||||
|
`ar-SA`, `ca`, `cs`, `da`, `de-DE`, `el`, `en-AU`, `en-CA`, `en-GB`, `en-US`, `es-ES`, `es-MX`, `fi`, `fr-CA`, `fr-FR`, `he`, `hi`, `hr`, `hu`, `id`, `it`, `ja`, `ko`, `ms`, `nl-NL`, `no`, `pl`, `pt-BR`, `pt-PT`, `ro`, `ru`, `sk`, `sv`, `th`, `tr`, `uk`, `vi`, `zh-Hans`, `zh-Hant`
|
||||||
|
|
||||||
|
## Dynamic Configuration
|
||||||
|
|
||||||
|
Use JavaScript for dynamic values like copyright year or fetched translations.
|
||||||
|
|
||||||
|
### Basic Dynamic Config
|
||||||
|
|
||||||
|
```js
|
||||||
|
// store.config.js
|
||||||
|
const baseConfig = require("./store.config.json");
|
||||||
|
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
...baseConfig,
|
||||||
|
apple: {
|
||||||
|
...baseConfig.apple,
|
||||||
|
copyright: `${year} Your Company, Inc.`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Async Configuration (External Localization)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// store.config.js
|
||||||
|
module.exports = async () => {
|
||||||
|
const baseConfig = require("./store.config.json");
|
||||||
|
|
||||||
|
// Fetch translations from CMS/localization service
|
||||||
|
const translations = await fetch("https://api.example.com/app-store-copy").then((r) => r.json());
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
apple: {
|
||||||
|
...baseConfig.apple,
|
||||||
|
info: translations,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment-Based Config
|
||||||
|
|
||||||
|
```js
|
||||||
|
// store.config.js
|
||||||
|
const baseConfig = require("./store.config.json");
|
||||||
|
|
||||||
|
const isProduction = process.env.EAS_BUILD_PROFILE === "production";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
...baseConfig,
|
||||||
|
apple: {
|
||||||
|
...baseConfig.apple,
|
||||||
|
info: {
|
||||||
|
"en-US": {
|
||||||
|
...baseConfig.apple.info["en-US"],
|
||||||
|
promoText: isProduction
|
||||||
|
? "Download now and get started!"
|
||||||
|
: "[BETA] Help us test new features!",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `eas.json` to use JS config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"metadataPath": "./store.config.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Age Rating (Advisory)
|
||||||
|
|
||||||
|
Answer content questions honestly to get an appropriate age rating.
|
||||||
|
|
||||||
|
**Content Descriptors:**
|
||||||
|
|
||||||
|
- `NONE` - Content not present
|
||||||
|
- `INFREQUENT_OR_MILD` - Occasional mild content
|
||||||
|
- `FREQUENT_OR_INTENSE` - Regular or strong content
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"advisory": {
|
||||||
|
"alcoholTobaccoOrDrugUseOrReferences": "NONE",
|
||||||
|
"contests": "NONE",
|
||||||
|
"gambling": false,
|
||||||
|
"gamblingSimulated": "NONE",
|
||||||
|
"horrorOrFearThemes": "NONE",
|
||||||
|
"matureOrSuggestiveThemes": "NONE",
|
||||||
|
"medicalOrTreatmentInformation": "NONE",
|
||||||
|
"profanityOrCrudeHumor": "NONE",
|
||||||
|
"sexualContentGraphicAndNudity": "NONE",
|
||||||
|
"sexualContentOrNudity": "NONE",
|
||||||
|
"unrestrictedWebAccess": false,
|
||||||
|
"violenceCartoonOrFantasy": "NONE",
|
||||||
|
"violenceRealistic": "NONE",
|
||||||
|
"violenceRealisticProlongedGraphicOrSadistic": "NONE",
|
||||||
|
"seventeenPlus": false,
|
||||||
|
"kidsAgeBand": "NINE_TO_ELEVEN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kids Age Bands:** `FIVE_AND_UNDER`, `SIX_TO_EIGHT`, `NINE_TO_ELEVEN`
|
||||||
|
|
||||||
|
## Release Strategy
|
||||||
|
|
||||||
|
Control how your app rolls out to users.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"release": {
|
||||||
|
"automaticRelease": true,
|
||||||
|
"phasedRelease": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
- `automaticRelease: true` - Release immediately upon approval
|
||||||
|
- `automaticRelease: false` - Manual release after approval
|
||||||
|
- `automaticRelease: "2025-02-01T10:00:00Z"` - Schedule release (RFC 3339)
|
||||||
|
- `phasedRelease: true` - 7-day gradual rollout (1%, 2%, 5%, 10%, 20%, 50%, 100%)
|
||||||
|
|
||||||
|
## Review Information
|
||||||
|
|
||||||
|
Provide contact info and test credentials for the App Review team.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"review": {
|
||||||
|
"firstName": "Jane",
|
||||||
|
"lastName": "Smith",
|
||||||
|
"email": "app-review@company.com",
|
||||||
|
"phone": "+1 (555) 123-4567",
|
||||||
|
"demoUsername": "demo@example.com",
|
||||||
|
"demoPassword": "ReviewDemo2025!",
|
||||||
|
"notes": "To test premium features:\n1. Log in with demo credentials\n2. Navigate to Settings > Subscription\n3. Tap 'Restore Purchase' - sandbox purchase will be restored\n\nFor location features, allow location access when prompted."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## ASO Checklist
|
||||||
|
|
||||||
|
### Before Each Release
|
||||||
|
|
||||||
|
- [ ] Update keywords based on performance data
|
||||||
|
- [ ] Refresh description with new features
|
||||||
|
- [ ] Write compelling release notes
|
||||||
|
- [ ] Update promo text if running campaigns
|
||||||
|
- [ ] Verify all URLs are valid
|
||||||
|
|
||||||
|
### Monthly ASO Tasks
|
||||||
|
|
||||||
|
- [ ] Analyze keyword rankings
|
||||||
|
- [ ] Research competitor keywords
|
||||||
|
- [ ] Check conversion rates in App Analytics
|
||||||
|
- [ ] Review user feedback for keyword ideas
|
||||||
|
- [ ] A/B test screenshots in App Store Connect
|
||||||
|
|
||||||
|
### Keyword Research Tips
|
||||||
|
|
||||||
|
1. **Brainstorm features** - List all app capabilities
|
||||||
|
2. **Mine reviews** - Find words users actually use
|
||||||
|
3. **Analyze competitors** - Check their titles/subtitles
|
||||||
|
4. **Use long-tail keywords** - Less competition, higher intent
|
||||||
|
5. **Consider misspellings** - Common typos can drive traffic
|
||||||
|
6. **Track seasonality** - Some keywords peak at certain times
|
||||||
|
|
||||||
|
### Metrics to Monitor
|
||||||
|
|
||||||
|
- **Impressions** - How often your app appears in search
|
||||||
|
- **Product Page Views** - Users who tap to learn more
|
||||||
|
- **Conversion Rate** - Views → Downloads
|
||||||
|
- **Keyword Rankings** - Position for target keywords
|
||||||
|
- **Category Ranking** - Position in your categories
|
||||||
|
|
||||||
|
## VS Code Integration
|
||||||
|
|
||||||
|
Install the [Expo Tools extension](https://marketplace.visualstudio.com/items?itemName=expo.vscode-expo-tools) for:
|
||||||
|
|
||||||
|
- Auto-complete for all schema properties
|
||||||
|
- Inline validation and warnings
|
||||||
|
- Quick fixes for common issues
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### "Binary not found"
|
||||||
|
|
||||||
|
Push a binary with `eas submit` before pushing metadata.
|
||||||
|
|
||||||
|
### "Invalid keywords"
|
||||||
|
|
||||||
|
- Check total length is ≤100 characters
|
||||||
|
- Remove spaces after commas
|
||||||
|
- Remove duplicate words
|
||||||
|
|
||||||
|
### "Description too long"
|
||||||
|
|
||||||
|
Description maximum is 4000 characters.
|
||||||
|
|
||||||
|
### Pull doesn't update JS config
|
||||||
|
|
||||||
|
`eas metadata:pull` creates a JSON file; import it into your JS config.
|
||||||
|
|
||||||
|
## CI/CD Integration
|
||||||
|
|
||||||
|
Automate metadata updates in your deployment pipeline:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .eas/workflows/release.yml
|
||||||
|
jobs:
|
||||||
|
submit-and-metadata:
|
||||||
|
steps:
|
||||||
|
- name: Submit to App Store
|
||||||
|
run: eas submit -p ios --latest
|
||||||
|
|
||||||
|
- name: Push Metadata
|
||||||
|
run: eas metadata:push
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- Update metadata every 4-6 weeks for optimal ASO
|
||||||
|
- 70% of App Store visitors use search to find apps
|
||||||
|
- Apps with 4+ star ratings get featured more often
|
||||||
|
- Localized apps see 128% more downloads per country
|
||||||
|
- First 3 lines of description are most critical (shown before "more")
|
||||||
|
- Use all 100 keyword characters—every character counts
|
||||||
357
.agents/skills/expo-deployment/references/ios-app-store.md
Normal file
357
.agents/skills/expo-deployment/references/ios-app-store.md
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
# Submitting to iOS App Store
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **Apple Developer Account** - Enroll at [developer.apple.com](https://developer.apple.com)
|
||||||
|
2. **App Store Connect App** - Create your app record before first submission
|
||||||
|
3. **Apple Credentials** - Configure via EAS or environment variables
|
||||||
|
|
||||||
|
## Credential Setup
|
||||||
|
|
||||||
|
### Using EAS Credentials
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eas credentials -p ios
|
||||||
|
```
|
||||||
|
|
||||||
|
This interactive flow helps you:
|
||||||
|
|
||||||
|
- Create or select a distribution certificate
|
||||||
|
- Create or select a provisioning profile
|
||||||
|
- Configure App Store Connect API key (recommended)
|
||||||
|
|
||||||
|
### App Store Connect API Key (Recommended)
|
||||||
|
|
||||||
|
API keys avoid 2FA prompts in CI/CD:
|
||||||
|
|
||||||
|
1. Go to App Store Connect → Users and Access → Keys
|
||||||
|
2. Click "+" to create a new key
|
||||||
|
3. Select "App Manager" role (minimum for submissions)
|
||||||
|
4. Download the `.p8` key file
|
||||||
|
|
||||||
|
Configure in `eas.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"submit": {
|
||||||
|
"production": {
|
||||||
|
"ios": {
|
||||||
|
"ascApiKeyPath": "./AuthKey_XXXXX.p8",
|
||||||
|
"ascApiKeyIssuerId": "xxxxx-xxxx-xxxx-xxxx-xxxxx",
|
||||||
|
"ascApiKeyId": "XXXXXXXXXX"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EXPO_ASC_API_KEY_PATH=./AuthKey.p8
|
||||||
|
EXPO_ASC_API_KEY_ISSUER_ID=xxxxx-xxxx-xxxx-xxxx-xxxxx
|
||||||
|
EXPO_ASC_API_KEY_ID=XXXXXXXXXX
|
||||||
|
```
|
||||||
|
|
||||||
|
### Apple ID Authentication (Alternative)
|
||||||
|
|
||||||
|
For manual submissions, you can use Apple ID:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EXPO_APPLE_ID=your@email.com
|
||||||
|
EXPO_APPLE_TEAM_ID=XXXXXXXXXX
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Requires app-specific password for accounts with 2FA.
|
||||||
|
|
||||||
|
## Submission Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and submit to App Store Connect
|
||||||
|
eas build -p ios --profile production --submit
|
||||||
|
|
||||||
|
# Submit latest build
|
||||||
|
eas submit -p ios --latest
|
||||||
|
|
||||||
|
# Submit specific build
|
||||||
|
eas submit -p ios --id BUILD_ID
|
||||||
|
|
||||||
|
# Quick TestFlight submission
|
||||||
|
npx testflight
|
||||||
|
```
|
||||||
|
|
||||||
|
## App Store Connect Configuration
|
||||||
|
|
||||||
|
### First-Time Setup
|
||||||
|
|
||||||
|
Before submitting, complete in App Store Connect:
|
||||||
|
|
||||||
|
1. **App Information**
|
||||||
|
- Primary language
|
||||||
|
- Bundle ID (must match `app.json`)
|
||||||
|
- SKU (unique identifier)
|
||||||
|
|
||||||
|
2. **Pricing and Availability**
|
||||||
|
- Price tier
|
||||||
|
- Available countries
|
||||||
|
|
||||||
|
3. **App Privacy**
|
||||||
|
- Privacy policy URL
|
||||||
|
- Data collection declarations
|
||||||
|
|
||||||
|
4. **App Review Information**
|
||||||
|
- Contact information
|
||||||
|
- Demo account (if login required)
|
||||||
|
- Notes for reviewers
|
||||||
|
|
||||||
|
### EAS Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 16.0.1",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"production": {
|
||||||
|
"ios": {
|
||||||
|
"resourceClass": "m-medium",
|
||||||
|
"autoIncrement": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {
|
||||||
|
"ios": {
|
||||||
|
"appleId": "your@email.com",
|
||||||
|
"ascAppId": "1234567890",
|
||||||
|
"appleTeamId": "XXXXXXXXXX"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Find `ascAppId` in App Store Connect → App Information → Apple ID.
|
||||||
|
|
||||||
|
## TestFlight vs App Store
|
||||||
|
|
||||||
|
### TestFlight (Beta Testing)
|
||||||
|
|
||||||
|
- Builds go to TestFlight automatically after submission
|
||||||
|
- Internal testers (up to 100) - immediate access
|
||||||
|
- External testers (up to 10,000) - requires beta review
|
||||||
|
- Builds expire after 90 days
|
||||||
|
|
||||||
|
### App Store (Production)
|
||||||
|
|
||||||
|
- Requires passing App Review
|
||||||
|
- Submit for review from App Store Connect
|
||||||
|
- Choose release timing (immediate, scheduled, manual)
|
||||||
|
|
||||||
|
## App Review Process
|
||||||
|
|
||||||
|
### What Reviewers Check
|
||||||
|
|
||||||
|
1. **Functionality** - App works as described
|
||||||
|
2. **UI/UX** - Follows Human Interface Guidelines
|
||||||
|
3. **Content** - Appropriate and accurate
|
||||||
|
4. **Privacy** - Data handling matches declarations
|
||||||
|
5. **Legal** - Complies with local laws
|
||||||
|
|
||||||
|
### Common Rejection Reasons
|
||||||
|
|
||||||
|
| Issue | Solution |
|
||||||
|
| ------------------------------------- | ---------------------------------- |
|
||||||
|
| Crashes/bugs | Test thoroughly before submission |
|
||||||
|
| Incomplete metadata | Fill all required fields |
|
||||||
|
| Placeholder content | Remove "lorem ipsum" and test data |
|
||||||
|
| Missing login credentials | Provide demo account |
|
||||||
|
| Privacy policy missing | Add URL in App Store Connect |
|
||||||
|
| Guideline 4.2 (minimum functionality) | Ensure app provides value |
|
||||||
|
|
||||||
|
### Expedited Review
|
||||||
|
|
||||||
|
Request expedited review for:
|
||||||
|
|
||||||
|
- Critical bug fixes
|
||||||
|
- Time-sensitive events
|
||||||
|
- Security issues
|
||||||
|
|
||||||
|
Go to App Store Connect → your app → App Review → Request Expedited Review.
|
||||||
|
|
||||||
|
## Version and Build Numbers
|
||||||
|
|
||||||
|
iOS uses two version identifiers:
|
||||||
|
|
||||||
|
- **Version** (`CFBundleShortVersionString`): User-facing, e.g., "1.2.3"
|
||||||
|
- **Build Number** (`CFBundleVersion`): Internal, must increment for each upload
|
||||||
|
|
||||||
|
Configure in `app.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"ios": {
|
||||||
|
"buildNumber": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
With `autoIncrement: true`, EAS handles build numbers automatically.
|
||||||
|
|
||||||
|
## Release Options
|
||||||
|
|
||||||
|
### Automatic Release
|
||||||
|
|
||||||
|
Release immediately when approved:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apple": {
|
||||||
|
"release": {
|
||||||
|
"automaticRelease": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scheduled Release
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apple": {
|
||||||
|
"release": {
|
||||||
|
"automaticRelease": "2025-03-01T10:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phased Release
|
||||||
|
|
||||||
|
Gradual rollout over 7 days:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apple": {
|
||||||
|
"release": {
|
||||||
|
"phasedRelease": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rollout: Day 1 (1%) → Day 2 (2%) → Day 3 (5%) → Day 4 (10%) → Day 5 (20%) → Day 6 (50%) → Day 7 (100%)
|
||||||
|
|
||||||
|
## Certificates and Provisioning
|
||||||
|
|
||||||
|
### Distribution Certificate
|
||||||
|
|
||||||
|
- Required for App Store submissions
|
||||||
|
- Limited to 3 per Apple Developer account
|
||||||
|
- Valid for 1 year
|
||||||
|
- EAS manages automatically
|
||||||
|
|
||||||
|
### Provisioning Profile
|
||||||
|
|
||||||
|
- Links app, certificate, and entitlements
|
||||||
|
- App Store profiles don't include device UDIDs
|
||||||
|
- EAS creates and manages automatically
|
||||||
|
|
||||||
|
### Check Current Credentials
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eas credentials -p ios
|
||||||
|
|
||||||
|
# Sync with Apple Developer Portal
|
||||||
|
eas credentials -p ios --sync
|
||||||
|
```
|
||||||
|
|
||||||
|
## App Store Metadata
|
||||||
|
|
||||||
|
Use EAS Metadata to manage App Store listing from code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull existing metadata
|
||||||
|
eas metadata:pull
|
||||||
|
|
||||||
|
# Push changes
|
||||||
|
eas metadata:push
|
||||||
|
```
|
||||||
|
|
||||||
|
See ./app-store-metadata.md for detailed configuration.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "No suitable application records found"
|
||||||
|
|
||||||
|
Create the app in App Store Connect first with matching bundle ID.
|
||||||
|
|
||||||
|
### "The bundle version must be higher"
|
||||||
|
|
||||||
|
Increment build number. With `autoIncrement: true`, this is automatic.
|
||||||
|
|
||||||
|
### "Missing compliance information"
|
||||||
|
|
||||||
|
Add export compliance to `app.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"ios": {
|
||||||
|
"config": {
|
||||||
|
"usesNonExemptEncryption": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Invalid provisioning profile"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eas credentials -p ios --sync
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build stuck in "Processing"
|
||||||
|
|
||||||
|
App Store Connect processing can take 5-30 minutes. Check status in App Store Connect → TestFlight.
|
||||||
|
|
||||||
|
## CI/CD Integration
|
||||||
|
|
||||||
|
For automated submissions in CI/CD:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .eas/workflows/release.yml
|
||||||
|
name: Release to App Store
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
type: build
|
||||||
|
params:
|
||||||
|
platform: ios
|
||||||
|
profile: production
|
||||||
|
|
||||||
|
submit:
|
||||||
|
type: submit
|
||||||
|
needs: [build]
|
||||||
|
params:
|
||||||
|
platform: ios
|
||||||
|
profile: production
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- Submit to TestFlight early and often for feedback
|
||||||
|
- Use beta app review for external testers to catch issues before App Store review
|
||||||
|
- Respond to reviewer questions promptly in App Store Connect
|
||||||
|
- Keep demo account credentials up to date
|
||||||
|
- Monitor App Store Connect notifications for review updates
|
||||||
|
- Use phased release for major updates to catch issues early
|
||||||
246
.agents/skills/expo-deployment/references/play-store.md
Normal file
246
.agents/skills/expo-deployment/references/play-store.md
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
# Submitting to Google Play Store
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **Google Play Console Account** - Register at [play.google.com/console](https://play.google.com/console)
|
||||||
|
2. **App Created in Console** - Create your app listing before first submission
|
||||||
|
3. **Service Account** - For automated submissions via EAS
|
||||||
|
|
||||||
|
## Service Account Setup
|
||||||
|
|
||||||
|
### 1. Create Service Account
|
||||||
|
|
||||||
|
1. Go to Google Cloud Console → IAM & Admin → Service Accounts
|
||||||
|
2. Create a new service account
|
||||||
|
3. Grant the "Service Account User" role
|
||||||
|
4. Create and download a JSON key
|
||||||
|
|
||||||
|
### 2. Link to Play Console
|
||||||
|
|
||||||
|
1. Go to Play Console → Setup → API access
|
||||||
|
2. Click "Link" next to your Google Cloud project
|
||||||
|
3. Under "Service accounts", click "Manage Play Console permissions"
|
||||||
|
4. Grant "Release to production" permission (or appropriate track permissions)
|
||||||
|
|
||||||
|
### 3. Configure EAS
|
||||||
|
|
||||||
|
Add the service account key path to `eas.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"submit": {
|
||||||
|
"production": {
|
||||||
|
"android": {
|
||||||
|
"serviceAccountKeyPath": "./google-service-account.json",
|
||||||
|
"track": "internal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Store the key file securely and add it to `.gitignore`.
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
For CI/CD, use environment variables instead of file paths:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Base64-encoded service account JSON
|
||||||
|
EXPO_ANDROID_SERVICE_ACCOUNT_KEY_BASE64=...
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use EAS Secrets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eas secret:create --name GOOGLE_SERVICE_ACCOUNT --value "$(cat google-service-account.json)" --type file
|
||||||
|
```
|
||||||
|
|
||||||
|
Then reference in `eas.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"submit": {
|
||||||
|
"production": {
|
||||||
|
"android": {
|
||||||
|
"serviceAccountKeyPath": "@secret:GOOGLE_SERVICE_ACCOUNT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Release Tracks
|
||||||
|
|
||||||
|
Google Play uses tracks for staged rollouts:
|
||||||
|
|
||||||
|
| Track | Purpose |
|
||||||
|
| ------------ | ------------------------------------ |
|
||||||
|
| `internal` | Internal testing (up to 100 testers) |
|
||||||
|
| `alpha` | Closed testing |
|
||||||
|
| `beta` | Open testing |
|
||||||
|
| `production` | Public release |
|
||||||
|
|
||||||
|
### Track Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"submit": {
|
||||||
|
"production": {
|
||||||
|
"android": {
|
||||||
|
"track": "production",
|
||||||
|
"releaseStatus": "completed"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"android": {
|
||||||
|
"track": "internal",
|
||||||
|
"releaseStatus": "completed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Release Status Options
|
||||||
|
|
||||||
|
- `completed` - Immediately available on the track
|
||||||
|
- `draft` - Upload only, release manually in Console
|
||||||
|
- `halted` - Pause an in-progress rollout
|
||||||
|
- `inProgress` - Staged rollout (requires `rollout` percentage)
|
||||||
|
|
||||||
|
## Staged Rollout
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"submit": {
|
||||||
|
"production": {
|
||||||
|
"android": {
|
||||||
|
"track": "production",
|
||||||
|
"releaseStatus": "inProgress",
|
||||||
|
"rollout": 0.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This releases to 10% of users. Increase via Play Console or subsequent submissions.
|
||||||
|
|
||||||
|
## Submission Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and submit to internal track
|
||||||
|
eas build -p android --profile production --submit
|
||||||
|
|
||||||
|
# Submit existing build to Play Store
|
||||||
|
eas submit -p android --latest
|
||||||
|
|
||||||
|
# Submit specific build
|
||||||
|
eas submit -p android --id BUILD_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
## App Signing
|
||||||
|
|
||||||
|
### Google Play App Signing (Recommended)
|
||||||
|
|
||||||
|
EAS uses Google Play App Signing by default:
|
||||||
|
|
||||||
|
1. First upload: EAS creates upload key, Play Store manages signing key
|
||||||
|
2. Play Store re-signs your app with the signing key
|
||||||
|
3. Upload key can be reset if compromised
|
||||||
|
|
||||||
|
### Checking Signing Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eas credentials -p android
|
||||||
|
```
|
||||||
|
|
||||||
|
## Version Codes
|
||||||
|
|
||||||
|
Android requires incrementing `versionCode` for each upload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"build": {
|
||||||
|
"production": {
|
||||||
|
"autoIncrement": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
With `appVersionSource: "remote"`, EAS tracks version codes automatically.
|
||||||
|
|
||||||
|
## First Submission Checklist
|
||||||
|
|
||||||
|
Before your first Play Store submission:
|
||||||
|
|
||||||
|
- [ ] Create app in Google Play Console
|
||||||
|
- [ ] Complete app content declaration (privacy policy, ads, etc.)
|
||||||
|
- [ ] Set up store listing (title, description, screenshots)
|
||||||
|
- [ ] Complete content rating questionnaire
|
||||||
|
- [ ] Set up pricing and distribution
|
||||||
|
- [ ] Create service account with proper permissions
|
||||||
|
- [ ] Configure `eas.json` with service account path
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### "App not found"
|
||||||
|
|
||||||
|
The app must exist in Play Console before EAS can submit. Create it manually first.
|
||||||
|
|
||||||
|
### "Version code already used"
|
||||||
|
|
||||||
|
Increment `versionCode` in `app.json` or use `autoIncrement: true` in `eas.json`.
|
||||||
|
|
||||||
|
### "Service account lacks permission"
|
||||||
|
|
||||||
|
Ensure the service account has "Release to production" permission in Play Console → API access.
|
||||||
|
|
||||||
|
### "APK not acceptable"
|
||||||
|
|
||||||
|
Play Store requires AAB (Android App Bundle) for new apps:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"build": {
|
||||||
|
"production": {
|
||||||
|
"android": {
|
||||||
|
"buildType": "app-bundle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Internal Testing Distribution
|
||||||
|
|
||||||
|
For quick internal distribution without Play Store:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build with internal distribution
|
||||||
|
eas build -p android --profile development
|
||||||
|
|
||||||
|
# Share the APK link with testers
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use EAS Update for OTA updates to existing installs.
|
||||||
|
|
||||||
|
## Monitoring Submissions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check submission status
|
||||||
|
eas submit:list -p android
|
||||||
|
|
||||||
|
# View specific submission
|
||||||
|
eas submit:view SUBMISSION_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- Start with `internal` track for testing before production
|
||||||
|
- Use staged rollouts for production releases
|
||||||
|
- Keep service account key secure - never commit to git
|
||||||
|
- Set up Play Console notifications for review status
|
||||||
|
- Pre-launch reports in Play Console catch issues before review
|
||||||
59
.agents/skills/expo-deployment/references/testflight.md
Normal file
59
.agents/skills/expo-deployment/references/testflight.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# TestFlight
|
||||||
|
|
||||||
|
Always ship to TestFlight first. Internal testers, then external testers, then App Store. Never skip this.
|
||||||
|
|
||||||
|
## Submit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx testflight
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it. One command builds and submits to TestFlight.
|
||||||
|
|
||||||
|
## Skip the Prompts
|
||||||
|
|
||||||
|
Set these once and forget:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EXPO_APPLE_ID=you@email.com
|
||||||
|
EXPO_APPLE_TEAM_ID=XXXXXXXXXX
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI prints your Team ID when you run `npx testflight`. Copy it.
|
||||||
|
|
||||||
|
## Why TestFlight First
|
||||||
|
|
||||||
|
- Internal testers get builds instantly (no review)
|
||||||
|
- External testers require one Beta App Review, then instant updates
|
||||||
|
- Catch crashes before App Store review rejects you
|
||||||
|
- TestFlight crash reports are better than App Store crash reports
|
||||||
|
- 90 days to test before builds expire
|
||||||
|
- Real users on real devices, not simulators
|
||||||
|
|
||||||
|
## Tester Strategy
|
||||||
|
|
||||||
|
**Internal (100 max)**: Your team. Immediate access. Use for every build.
|
||||||
|
|
||||||
|
**External (10,000 max)**: Beta users. First build needs review (~24h), then instant. Always have an external group—even if it's just friends. Real feedback beats assumptions.
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- Submit to external TestFlight the moment internal looks stable
|
||||||
|
- Beta App Review is faster and more lenient than App Store Review
|
||||||
|
- Add release notes—testers actually read them
|
||||||
|
- Use TestFlight's built-in feedback and screenshots
|
||||||
|
- Never go straight to App Store. Ever.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**"No suitable application records found"**
|
||||||
|
Create the app in App Store Connect first. Bundle ID must match.
|
||||||
|
|
||||||
|
**"The bundle version must be higher"**
|
||||||
|
Use `autoIncrement: true` in `eas.json`. Problem solved.
|
||||||
|
|
||||||
|
**Credentials issues**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eas credentials -p ios
|
||||||
|
```
|
||||||
200
.agents/skills/expo-deployment/references/workflows.md
Normal file
200
.agents/skills/expo-deployment/references/workflows.md
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
# EAS Workflows
|
||||||
|
|
||||||
|
Automate builds, submissions, and deployments with EAS Workflows.
|
||||||
|
|
||||||
|
## Web Deployment
|
||||||
|
|
||||||
|
Deploy web apps on push to main:
|
||||||
|
|
||||||
|
`.eas/workflows/deploy.yml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
# https://docs.expo.dev/eas/workflows/syntax/#deploy
|
||||||
|
jobs:
|
||||||
|
deploy_web:
|
||||||
|
type: deploy
|
||||||
|
params:
|
||||||
|
prod: true
|
||||||
|
```
|
||||||
|
|
||||||
|
## PR Previews
|
||||||
|
|
||||||
|
### Web PR Previews
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Web PR Preview
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
preview:
|
||||||
|
type: deploy
|
||||||
|
params:
|
||||||
|
prod: false
|
||||||
|
```
|
||||||
|
|
||||||
|
### Native PR Previews with EAS Updates
|
||||||
|
|
||||||
|
Deploy OTA updates for pull requests:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: PR Preview
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
type: update
|
||||||
|
params:
|
||||||
|
branch: "pr-${{ github.event.pull_request.number }}"
|
||||||
|
message: "PR #${{ github.event.pull_request.number }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Release
|
||||||
|
|
||||||
|
Complete release workflow for both platforms:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-ios:
|
||||||
|
type: build
|
||||||
|
params:
|
||||||
|
platform: ios
|
||||||
|
profile: production
|
||||||
|
|
||||||
|
build-android:
|
||||||
|
type: build
|
||||||
|
params:
|
||||||
|
platform: android
|
||||||
|
profile: production
|
||||||
|
|
||||||
|
submit-ios:
|
||||||
|
type: submit
|
||||||
|
needs: [build-ios]
|
||||||
|
params:
|
||||||
|
platform: ios
|
||||||
|
profile: production
|
||||||
|
|
||||||
|
submit-android:
|
||||||
|
type: submit
|
||||||
|
needs: [build-android]
|
||||||
|
params:
|
||||||
|
platform: android
|
||||||
|
profile: production
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build on Push
|
||||||
|
|
||||||
|
Trigger builds when pushing to specific branches:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- release/*
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
type: build
|
||||||
|
params:
|
||||||
|
platform: all
|
||||||
|
profile: production
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conditional Jobs
|
||||||
|
|
||||||
|
Run jobs based on conditions:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Conditional Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-changes:
|
||||||
|
type: run
|
||||||
|
params:
|
||||||
|
command: |
|
||||||
|
if git diff --name-only HEAD~1 | grep -q "^src/"; then
|
||||||
|
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
build:
|
||||||
|
type: build
|
||||||
|
needs: [check-changes]
|
||||||
|
if: needs.check-changes.outputs.has_changes == 'true'
|
||||||
|
params:
|
||||||
|
platform: all
|
||||||
|
profile: production
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow Syntax Reference
|
||||||
|
|
||||||
|
### Triggers
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
tags: ["v*"]
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
schedule:
|
||||||
|
- cron: "0 0 * * *" # Daily at midnight
|
||||||
|
workflow_dispatch: # Manual trigger
|
||||||
|
```
|
||||||
|
|
||||||
|
### Job Types
|
||||||
|
|
||||||
|
| Type | Purpose |
|
||||||
|
| -------- | ----------------------- |
|
||||||
|
| `build` | Create app builds |
|
||||||
|
| `submit` | Submit to app stores |
|
||||||
|
| `update` | Publish OTA updates |
|
||||||
|
| `deploy` | Deploy web apps |
|
||||||
|
| `run` | Execute custom commands |
|
||||||
|
|
||||||
|
### Job Dependencies
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jobs:
|
||||||
|
first:
|
||||||
|
type: build
|
||||||
|
params:
|
||||||
|
platform: ios
|
||||||
|
|
||||||
|
second:
|
||||||
|
type: submit
|
||||||
|
needs: [first] # Runs after 'first' completes
|
||||||
|
params:
|
||||||
|
platform: ios
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- Use `workflow_dispatch` for manual production releases
|
||||||
|
- Combine PR previews with GitHub status checks
|
||||||
|
- Use tags for versioned releases
|
||||||
|
- Keep sensitive values in EAS Secrets, not workflow files
|
||||||
173
.agents/skills/expo-dev-client/SKILL.md
Normal file
173
.agents/skills/expo-dev-client/SKILL.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
---
|
||||||
|
name: expo-dev-client
|
||||||
|
description: Build and distribute Expo development clients locally or via TestFlight
|
||||||
|
version: 1.0.0
|
||||||
|
license: MIT
|
||||||
|
---
|
||||||
|
|
||||||
|
Use EAS Build to create development clients for testing native code changes on physical devices. Use this for creating custom Expo Go clients for testing branches of your app.
|
||||||
|
|
||||||
|
## Important: When Development Clients Are Needed
|
||||||
|
|
||||||
|
**Only create development clients when your app requires custom native code.** Most apps work fine in Expo Go.
|
||||||
|
|
||||||
|
You need a dev client ONLY when using:
|
||||||
|
|
||||||
|
- Local Expo modules (custom native code)
|
||||||
|
- Apple targets (widgets, app clips, extensions)
|
||||||
|
- Third-party native modules not in Expo Go
|
||||||
|
|
||||||
|
**Try Expo Go first** with `npx expo start`. If everything works, you don't need a dev client.
|
||||||
|
|
||||||
|
## EAS Configuration
|
||||||
|
|
||||||
|
Ensure `eas.json` has a development profile:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 16.0.1",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"production": {
|
||||||
|
"autoIncrement": true
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"autoIncrement": true,
|
||||||
|
"developmentClient": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {},
|
||||||
|
"development": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key settings:
|
||||||
|
|
||||||
|
- `developmentClient: true` - Bundles expo-dev-client for development builds
|
||||||
|
- `autoIncrement: true` - Automatically increments build numbers
|
||||||
|
- `appVersionSource: "remote"` - Uses EAS as the source of truth for version numbers
|
||||||
|
|
||||||
|
## Building for TestFlight
|
||||||
|
|
||||||
|
Build iOS dev client and submit to TestFlight in one command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eas build -p ios --profile development --submit
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
|
||||||
|
1. Build the development client in the cloud
|
||||||
|
2. Automatically submit to App Store Connect
|
||||||
|
3. Send you an email when the build is ready in TestFlight
|
||||||
|
|
||||||
|
After receiving the TestFlight email:
|
||||||
|
|
||||||
|
1. Download the build from TestFlight on your device
|
||||||
|
2. Launch the app to see the expo-dev-client UI
|
||||||
|
3. Connect to your local Metro bundler or scan a QR code
|
||||||
|
|
||||||
|
## Building Locally
|
||||||
|
|
||||||
|
Build a development client on your machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# iOS (requires Xcode)
|
||||||
|
eas build -p ios --profile development --local
|
||||||
|
|
||||||
|
# Android
|
||||||
|
eas build -p android --profile development --local
|
||||||
|
```
|
||||||
|
|
||||||
|
Local builds output:
|
||||||
|
|
||||||
|
- iOS: `.ipa` file
|
||||||
|
- Android: `.apk` or `.aab` file
|
||||||
|
|
||||||
|
## Installing Local Builds
|
||||||
|
|
||||||
|
Install iOS build on simulator:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find the .app in the .tar.gz output
|
||||||
|
tar -xzf build-*.tar.gz
|
||||||
|
xcrun simctl install booted ./path/to/App.app
|
||||||
|
```
|
||||||
|
|
||||||
|
Install iOS build on device (requires signing):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use Xcode Devices window or ideviceinstaller
|
||||||
|
ideviceinstaller -i build.ipa
|
||||||
|
```
|
||||||
|
|
||||||
|
Install Android build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb install build.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building for Specific Platform
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# iOS only
|
||||||
|
eas build -p ios --profile development
|
||||||
|
|
||||||
|
# Android only
|
||||||
|
eas build -p android --profile development
|
||||||
|
|
||||||
|
# Both platforms
|
||||||
|
eas build --profile development
|
||||||
|
```
|
||||||
|
|
||||||
|
## Checking Build Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List recent builds
|
||||||
|
eas build:list
|
||||||
|
|
||||||
|
# View build details
|
||||||
|
eas build:view
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using the Dev Client
|
||||||
|
|
||||||
|
Once installed, the dev client provides:
|
||||||
|
|
||||||
|
- **Development server connection** - Enter your Metro bundler URL or scan QR
|
||||||
|
- **Build information** - View native build details
|
||||||
|
- **Launcher UI** - Switch between development servers
|
||||||
|
|
||||||
|
Connect to local development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start Metro bundler
|
||||||
|
npx expo start --dev-client
|
||||||
|
|
||||||
|
# Scan QR code with dev client or enter URL manually
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Build fails with signing errors:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eas credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
**Clear build cache:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eas build -p ios --profile development --clear-cache
|
||||||
|
```
|
||||||
|
|
||||||
|
**Check EAS CLI version:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eas --version
|
||||||
|
eas update
|
||||||
|
```
|
||||||
456
.agents/skills/expo-tailwind-setup/SKILL.md
Normal file
456
.agents/skills/expo-tailwind-setup/SKILL.md
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
---
|
||||||
|
name: expo-tailwind-setup
|
||||||
|
description: Set up Tailwind CSS v4 in Expo with react-native-css and NativeWind v5 for universal styling
|
||||||
|
version: 1.0.0
|
||||||
|
license: MIT
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tailwind CSS Setup for Expo with react-native-css
|
||||||
|
|
||||||
|
This guide covers setting up Tailwind CSS v4 in Expo using react-native-css and NativeWind v5 for universal styling across iOS, Android, and Web.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This setup uses:
|
||||||
|
|
||||||
|
- **Tailwind CSS v4** - Modern CSS-first configuration
|
||||||
|
- **react-native-css** - CSS runtime for React Native
|
||||||
|
- **NativeWind v5** - Metro transformer for Tailwind in React Native
|
||||||
|
- **@tailwindcss/postcss** - PostCSS plugin for Tailwind v4
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npx expo install tailwindcss@^4 nativewind@5.0.0-preview.2 react-native-css@0.0.0-nightly.5ce6396 @tailwindcss/postcss tailwind-merge clsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Add resolutions for lightningcss compatibility:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// package.json
|
||||||
|
{
|
||||||
|
"resolutions": {
|
||||||
|
"lightningcss": "1.30.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- autoprefixer is not needed in Expo because of lightningcss
|
||||||
|
- postcss is included in expo by default
|
||||||
|
|
||||||
|
## Configuration Files
|
||||||
|
|
||||||
|
### Metro Config
|
||||||
|
|
||||||
|
Create or update `metro.config.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// metro.config.js
|
||||||
|
const { getDefaultConfig } = require("expo/metro-config");
|
||||||
|
const { withNativewind } = require("nativewind/metro");
|
||||||
|
|
||||||
|
/** @type {import('expo/metro-config').MetroConfig} */
|
||||||
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
|
module.exports = withNativewind(config, {
|
||||||
|
// inline variables break PlatformColor in CSS variables
|
||||||
|
inlineVariables: false,
|
||||||
|
// We add className support manually
|
||||||
|
globalClassNamePolyfill: false,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### PostCSS Config
|
||||||
|
|
||||||
|
Create `postcss.config.mjs`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// postcss.config.mjs
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Global CSS
|
||||||
|
|
||||||
|
Create `src/global.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@import "tailwindcss/theme.css" layer(theme);
|
||||||
|
@import "tailwindcss/preflight.css" layer(base);
|
||||||
|
@import "tailwindcss/utilities.css";
|
||||||
|
|
||||||
|
/* Platform-specific font families */
|
||||||
|
@media android {
|
||||||
|
:root {
|
||||||
|
--font-mono: monospace;
|
||||||
|
--font-rounded: normal;
|
||||||
|
--font-serif: serif;
|
||||||
|
--font-sans: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media ios {
|
||||||
|
:root {
|
||||||
|
--font-mono: ui-monospace;
|
||||||
|
--font-serif: ui-serif;
|
||||||
|
--font-sans: system-ui;
|
||||||
|
--font-rounded: ui-rounded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## IMPORTANT: No Babel Config Needed
|
||||||
|
|
||||||
|
With Tailwind v4 and NativeWind v5, you do NOT need a babel.config.js for Tailwind. Remove any NativeWind babel presets if present:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// DELETE babel.config.js if it only contains NativeWind config
|
||||||
|
// The following is NO LONGER needed:
|
||||||
|
// module.exports = function (api) {
|
||||||
|
// api.cache(true);
|
||||||
|
// return {
|
||||||
|
// presets: [
|
||||||
|
// ["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||||
|
// "nativewind/babel",
|
||||||
|
// ],
|
||||||
|
// };
|
||||||
|
// };
|
||||||
|
```
|
||||||
|
|
||||||
|
## CSS Component Wrappers
|
||||||
|
|
||||||
|
Since react-native-css requires explicit CSS element wrapping, create reusable components:
|
||||||
|
|
||||||
|
### Main Components (`src/tw/index.tsx`)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useCssElement, useNativeVariable as useFunctionalVariable } from "react-native-css";
|
||||||
|
|
||||||
|
import { Link as RouterLink } from "expo-router";
|
||||||
|
import Animated from "react-native-reanimated";
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
View as RNView,
|
||||||
|
Text as RNText,
|
||||||
|
Pressable as RNPressable,
|
||||||
|
ScrollView as RNScrollView,
|
||||||
|
TouchableHighlight as RNTouchableHighlight,
|
||||||
|
TextInput as RNTextInput,
|
||||||
|
StyleSheet,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
// CSS-enabled Link
|
||||||
|
export const Link = (props: React.ComponentProps<typeof RouterLink> & { className?: string }) => {
|
||||||
|
return useCssElement(RouterLink, props, { className: "style" });
|
||||||
|
};
|
||||||
|
|
||||||
|
Link.Trigger = RouterLink.Trigger;
|
||||||
|
Link.Menu = RouterLink.Menu;
|
||||||
|
Link.MenuAction = RouterLink.MenuAction;
|
||||||
|
Link.Preview = RouterLink.Preview;
|
||||||
|
|
||||||
|
// CSS Variable hook
|
||||||
|
export const useCSSVariable =
|
||||||
|
process.env.EXPO_OS !== "web" ? useFunctionalVariable : (variable: string) => `var(${variable})`;
|
||||||
|
|
||||||
|
// View
|
||||||
|
export type ViewProps = React.ComponentProps<typeof RNView> & {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const View = (props: ViewProps) => {
|
||||||
|
return useCssElement(RNView, props, { className: "style" });
|
||||||
|
};
|
||||||
|
View.displayName = "CSS(View)";
|
||||||
|
|
||||||
|
// Text
|
||||||
|
export const Text = (props: React.ComponentProps<typeof RNText> & { className?: string }) => {
|
||||||
|
return useCssElement(RNText, props, { className: "style" });
|
||||||
|
};
|
||||||
|
Text.displayName = "CSS(Text)";
|
||||||
|
|
||||||
|
// ScrollView
|
||||||
|
export const ScrollView = (
|
||||||
|
props: React.ComponentProps<typeof RNScrollView> & {
|
||||||
|
className?: string;
|
||||||
|
contentContainerClassName?: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
return useCssElement(RNScrollView, props, {
|
||||||
|
className: "style",
|
||||||
|
contentContainerClassName: "contentContainerStyle",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
ScrollView.displayName = "CSS(ScrollView)";
|
||||||
|
|
||||||
|
// Pressable
|
||||||
|
export const Pressable = (
|
||||||
|
props: React.ComponentProps<typeof RNPressable> & { className?: string },
|
||||||
|
) => {
|
||||||
|
return useCssElement(RNPressable, props, { className: "style" });
|
||||||
|
};
|
||||||
|
Pressable.displayName = "CSS(Pressable)";
|
||||||
|
|
||||||
|
// TextInput
|
||||||
|
export const TextInput = (
|
||||||
|
props: React.ComponentProps<typeof RNTextInput> & { className?: string },
|
||||||
|
) => {
|
||||||
|
return useCssElement(RNTextInput, props, { className: "style" });
|
||||||
|
};
|
||||||
|
TextInput.displayName = "CSS(TextInput)";
|
||||||
|
|
||||||
|
// AnimatedScrollView
|
||||||
|
export const AnimatedScrollView = (
|
||||||
|
props: React.ComponentProps<typeof Animated.ScrollView> & {
|
||||||
|
className?: string;
|
||||||
|
contentClassName?: string;
|
||||||
|
contentContainerClassName?: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
return useCssElement(Animated.ScrollView, props, {
|
||||||
|
className: "style",
|
||||||
|
contentClassName: "contentContainerStyle",
|
||||||
|
contentContainerClassName: "contentContainerStyle",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// TouchableHighlight with underlayColor extraction
|
||||||
|
function XXTouchableHighlight(props: React.ComponentProps<typeof RNTouchableHighlight>) {
|
||||||
|
const { underlayColor, ...style } = StyleSheet.flatten(props.style) || {};
|
||||||
|
return <RNTouchableHighlight underlayColor={underlayColor} {...props} style={style} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TouchableHighlight = (props: React.ComponentProps<typeof RNTouchableHighlight>) => {
|
||||||
|
return useCssElement(XXTouchableHighlight, props, { className: "style" });
|
||||||
|
};
|
||||||
|
TouchableHighlight.displayName = "CSS(TouchableHighlight)";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Image Component (`src/tw/image.tsx`)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useCssElement } from "react-native-css";
|
||||||
|
import React from "react";
|
||||||
|
import { StyleSheet } from "react-native";
|
||||||
|
import Animated from "react-native-reanimated";
|
||||||
|
import { Image as RNImage } from "expo-image";
|
||||||
|
|
||||||
|
const AnimatedExpoImage = Animated.createAnimatedComponent(RNImage);
|
||||||
|
|
||||||
|
export type ImageProps = React.ComponentProps<typeof Image>;
|
||||||
|
|
||||||
|
function CSSImage(props: React.ComponentProps<typeof AnimatedExpoImage>) {
|
||||||
|
// @ts-expect-error: Remap objectFit style to contentFit property
|
||||||
|
const { objectFit, objectPosition, ...style } = StyleSheet.flatten(props.style) || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedExpoImage
|
||||||
|
contentFit={objectFit}
|
||||||
|
contentPosition={objectPosition}
|
||||||
|
{...props}
|
||||||
|
source={typeof props.source === "string" ? { uri: props.source } : props.source}
|
||||||
|
// @ts-expect-error: Style is remapped above
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Image = (props: React.ComponentProps<typeof CSSImage> & { className?: string }) => {
|
||||||
|
return useCssElement(CSSImage, props, { className: "style" });
|
||||||
|
};
|
||||||
|
|
||||||
|
Image.displayName = "CSS(Image)";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Animated Components (`src/tw/animated.tsx`)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import * as TW from "./index";
|
||||||
|
import RNAnimated from "react-native-reanimated";
|
||||||
|
|
||||||
|
export const Animated = {
|
||||||
|
...RNAnimated,
|
||||||
|
View: RNAnimated.createAnimatedComponent(TW.View),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Import CSS-wrapped components from your tw directory:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { View, Text, ScrollView, Image } from "@/tw";
|
||||||
|
|
||||||
|
export default function MyScreen() {
|
||||||
|
return (
|
||||||
|
<ScrollView className="flex-1 bg-white">
|
||||||
|
<View className="p-4 gap-4">
|
||||||
|
<Text className="text-xl font-bold text-gray-900">Hello Tailwind!</Text>
|
||||||
|
<Image
|
||||||
|
className="w-full h-48 rounded-lg object-cover"
|
||||||
|
source={{ uri: "https://example.com/image.jpg" }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Theme Variables
|
||||||
|
|
||||||
|
Add custom theme variables in your global.css using `@theme`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@layer theme {
|
||||||
|
@theme {
|
||||||
|
/* Custom fonts */
|
||||||
|
--font-rounded: "SF Pro Rounded", sans-serif;
|
||||||
|
|
||||||
|
/* Custom line heights */
|
||||||
|
--text-xs--line-height: calc(1em / 0.75);
|
||||||
|
--text-sm--line-height: calc(1.25em / 0.875);
|
||||||
|
--text-base--line-height: calc(1.5em / 1);
|
||||||
|
|
||||||
|
/* Custom leading scales */
|
||||||
|
--leading-tight: 1.25em;
|
||||||
|
--leading-snug: 1.375em;
|
||||||
|
--leading-normal: 1.5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Platform-Specific Styles
|
||||||
|
|
||||||
|
Use platform media queries for platform-specific styling:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@media ios {
|
||||||
|
:root {
|
||||||
|
--font-sans: system-ui;
|
||||||
|
--font-rounded: ui-rounded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media android {
|
||||||
|
:root {
|
||||||
|
--font-sans: normal;
|
||||||
|
--font-rounded: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Apple System Colors with CSS Variables
|
||||||
|
|
||||||
|
Create a CSS file for Apple semantic colors:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* src/css/sf.css */
|
||||||
|
@layer base {
|
||||||
|
html {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Accent colors with light/dark mode */
|
||||||
|
--sf-blue: light-dark(rgb(0 122 255), rgb(10 132 255));
|
||||||
|
--sf-green: light-dark(rgb(52 199 89), rgb(48 209 89));
|
||||||
|
--sf-red: light-dark(rgb(255 59 48), rgb(255 69 58));
|
||||||
|
|
||||||
|
/* Gray scales */
|
||||||
|
--sf-gray: light-dark(rgb(142 142 147), rgb(142 142 147));
|
||||||
|
--sf-gray-2: light-dark(rgb(174 174 178), rgb(99 99 102));
|
||||||
|
|
||||||
|
/* Text colors */
|
||||||
|
--sf-text: light-dark(rgb(0 0 0), rgb(255 255 255));
|
||||||
|
--sf-text-2: light-dark(rgb(60 60 67 / 0.6), rgb(235 235 245 / 0.6));
|
||||||
|
|
||||||
|
/* Background colors */
|
||||||
|
--sf-bg: light-dark(rgb(255 255 255), rgb(0 0 0));
|
||||||
|
--sf-bg-2: light-dark(rgb(242 242 247), rgb(28 28 30));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* iOS native colors via platformColor */
|
||||||
|
@media ios {
|
||||||
|
:root {
|
||||||
|
--sf-blue: platformColor(systemBlue);
|
||||||
|
--sf-green: platformColor(systemGreen);
|
||||||
|
--sf-red: platformColor(systemRed);
|
||||||
|
--sf-gray: platformColor(systemGray);
|
||||||
|
--sf-text: platformColor(label);
|
||||||
|
--sf-text-2: platformColor(secondaryLabel);
|
||||||
|
--sf-bg: platformColor(systemBackground);
|
||||||
|
--sf-bg-2: platformColor(secondarySystemBackground);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Register as Tailwind theme colors */
|
||||||
|
@layer theme {
|
||||||
|
@theme {
|
||||||
|
--color-sf-blue: var(--sf-blue);
|
||||||
|
--color-sf-green: var(--sf-green);
|
||||||
|
--color-sf-red: var(--sf-red);
|
||||||
|
--color-sf-gray: var(--sf-gray);
|
||||||
|
--color-sf-text: var(--sf-text);
|
||||||
|
--color-sf-text-2: var(--sf-text-2);
|
||||||
|
--color-sf-bg: var(--sf-bg);
|
||||||
|
--color-sf-bg-2: var(--sf-bg-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then use in components:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Text className="text-sf-text">Primary text</Text>
|
||||||
|
<Text className="text-sf-text-2">Secondary text</Text>
|
||||||
|
<View className="bg-sf-bg">...</View>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using CSS Variables in JavaScript
|
||||||
|
|
||||||
|
Use the `useCSSVariable` hook:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useCSSVariable } from "@/tw";
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const blue = useCSSVariable("--sf-blue");
|
||||||
|
|
||||||
|
return <View style={{ borderColor: blue }} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Differences from NativeWind v4 / Tailwind v3
|
||||||
|
|
||||||
|
1. **No babel.config.js** - Configuration is now CSS-first
|
||||||
|
2. **PostCSS plugin** - Uses `@tailwindcss/postcss` instead of `tailwindcss`
|
||||||
|
3. **CSS imports** - Use `@import "tailwindcss/..."` instead of `@tailwind` directives
|
||||||
|
4. **Theme config** - Use `@theme` in CSS instead of `tailwind.config.js`
|
||||||
|
5. **Component wrappers** - Must wrap components with `useCssElement` for className support
|
||||||
|
6. **Metro config** - Use `withNativewind` with different options (`inlineVariables: false`)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Styles not applying
|
||||||
|
|
||||||
|
1. Ensure you have the CSS file imported in your app entry
|
||||||
|
2. Check that components are wrapped with `useCssElement`
|
||||||
|
3. Verify Metro config has `withNativewind` applied
|
||||||
|
|
||||||
|
### Platform colors not working
|
||||||
|
|
||||||
|
1. Use `platformColor()` in `@media ios` blocks
|
||||||
|
2. Fall back to `light-dark()` for web/Android
|
||||||
|
|
||||||
|
### TypeScript errors
|
||||||
|
|
||||||
|
Add className to component props:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
type Props = React.ComponentProps<typeof RNView> & { className?: string };
|
||||||
|
```
|
||||||
21
.agents/skills/heroui-native/LICENSE.txt
Normal file
21
.agents/skills/heroui-native/LICENSE.txt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 NextUI Inc.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
243
.agents/skills/heroui-native/SKILL.md
Normal file
243
.agents/skills/heroui-native/SKILL.md
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
---
|
||||||
|
name: heroui-native
|
||||||
|
description: "HeroUI Native component library for React Native (Tailwind v4 via Uniwind). Use when working with HeroUI Native components, installing HeroUI Native, customizing themes, or accessing component documentation. Keywords: HeroUI Native, heroui-native, React Native UI, Uniwind."
|
||||||
|
metadata:
|
||||||
|
author: heroui
|
||||||
|
version: "1.0.0"
|
||||||
|
---
|
||||||
|
|
||||||
|
# HeroUI Native Development Guide
|
||||||
|
|
||||||
|
HeroUI Native is a component library built on **Uniwind (Tailwind CSS for React Native)** and **React Native**, providing accessible, customizable UI components for mobile applications.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CRITICAL: Native Only - Do Not Use Web Patterns
|
||||||
|
|
||||||
|
**This guide is for HeroUI Native ONLY.** Do NOT use any prior knowledge of HeroUI React (web) patterns.
|
||||||
|
|
||||||
|
### What Changed in Native
|
||||||
|
|
||||||
|
| Feature | React (Web) | Native (Mobile) |
|
||||||
|
| ------------ | -------------------- | ----------------------------------- |
|
||||||
|
| **Styling** | Tailwind CSS v4 | Uniwind (Tailwind for React Native) |
|
||||||
|
| **Colors** | oklch format | HSL format |
|
||||||
|
| **Package** | `@heroui/react@beta` | `heroui-native` |
|
||||||
|
| **Platform** | Web browsers | iOS & Android |
|
||||||
|
|
||||||
|
### WRONG (React web patterns)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// DO NOT DO THIS - React web pattern
|
||||||
|
import { Button } from "@heroui/react";
|
||||||
|
import "./styles.css"; // CSS files don't work in React Native
|
||||||
|
|
||||||
|
<Button className="bg-blue-500">Click me</Button>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### CORRECT (Native patterns)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// DO THIS - Native pattern (Uniwind, React Native components)
|
||||||
|
import { Button } from "heroui-native";
|
||||||
|
|
||||||
|
<Button variant="primary" onPress={() => console.log("Pressed!")}>
|
||||||
|
Click me
|
||||||
|
</Button>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Always fetch Native docs before implementing.** Do not assume React web patterns work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
- Semantic variants (`primary`, `secondary`, `tertiary`) over visual descriptions
|
||||||
|
- Composition over configuration (compound components)
|
||||||
|
- Theme variables with HSL color format
|
||||||
|
- React Native StyleSheet patterns with Uniwind utilities
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accessing Documentation & Component Information
|
||||||
|
|
||||||
|
**For component details, examples, props, and implementation patterns, always fetch documentation:**
|
||||||
|
|
||||||
|
### Using Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all available components
|
||||||
|
node scripts/list_components.mjs
|
||||||
|
|
||||||
|
# Get component documentation (MDX)
|
||||||
|
node scripts/get_component_docs.mjs Button
|
||||||
|
node scripts/get_component_docs.mjs Button Card TextField
|
||||||
|
|
||||||
|
# Get theme variables
|
||||||
|
node scripts/get_theme.mjs
|
||||||
|
|
||||||
|
# Get non-component docs (guides, releases)
|
||||||
|
node scripts/get_docs.mjs /docs/native/getting-started/theming
|
||||||
|
```
|
||||||
|
|
||||||
|
### Direct MDX URLs
|
||||||
|
|
||||||
|
Component docs: `https://v3.heroui.com/docs/native/components/{component-name}.mdx`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Button: `https://v3.heroui.com/docs/native/components/button.mdx`
|
||||||
|
- Dialog: `https://v3.heroui.com/docs/native/components/dialog.mdx`
|
||||||
|
- TextField: `https://v3.heroui.com/docs/native/components/text-field.mdx`
|
||||||
|
|
||||||
|
Getting started guides: `https://v3.heroui.com/docs/native/getting-started/{topic}.mdx`
|
||||||
|
|
||||||
|
**Important:** Always fetch component docs before implementing. The MDX docs include complete examples, props, anatomy, and API references.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation Essentials
|
||||||
|
|
||||||
|
**CRITICAL**: HeroUI Native is currently in BETA.
|
||||||
|
|
||||||
|
### Quick Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i heroui-native
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required Peer Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i react-native-reanimated react-native-gesture-handler react-native-safe-area-context @gorhom/bottom-sheet react-native-svg react-native-worklets tailwind-merge tailwind-variants
|
||||||
|
```
|
||||||
|
|
||||||
|
### Framework Setup (Expo - Recommended)
|
||||||
|
|
||||||
|
1. **Install dependencies:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx create-expo-app MyApp
|
||||||
|
cd MyApp
|
||||||
|
npm i heroui-native uniwind tailwindcss
|
||||||
|
npm i react-native-reanimated react-native-gesture-handler react-native-safe-area-context @gorhom/bottom-sheet react-native-svg react-native-worklets tailwind-merge tailwind-variants
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create `global.css`:**
|
||||||
|
|
||||||
|
```css
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "uniwind";
|
||||||
|
@import "heroui-native/styles";
|
||||||
|
|
||||||
|
@source "./node_modules/heroui-native/lib";
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Wrap app with providers:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
|
import { HeroUINativeProvider } from "heroui-native";
|
||||||
|
import "./global.css";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
|
<HeroUINativeProvider>
|
||||||
|
<App />
|
||||||
|
</HeroUINativeProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Critical Setup Requirements
|
||||||
|
|
||||||
|
1. **Uniwind is Required** - HeroUI Native uses Uniwind (Tailwind CSS for React Native)
|
||||||
|
2. **HeroUINativeProvider Required** - Wrap your app with `HeroUINativeProvider`
|
||||||
|
3. **GestureHandlerRootView Required** - Wrap with `GestureHandlerRootView` from react-native-gesture-handler
|
||||||
|
4. **Use Compound Components** - Components use compound structure (e.g., `Card.Header`, `Card.Body`)
|
||||||
|
5. **Use onPress, not onClick** - React Native uses `onPress` event handlers
|
||||||
|
6. **Platform-Specific Code** - Use `Platform.OS` for iOS/Android differences
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Patterns
|
||||||
|
|
||||||
|
HeroUI Native uses **compound component patterns**. Each component has subcomponents accessed via dot notation.
|
||||||
|
|
||||||
|
**Example - Card:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Title</Card.Title>
|
||||||
|
<Card.Description>Description</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body>{/* Content */}</Card.Body>
|
||||||
|
<Card.Footer>{/* Actions */}</Card.Footer>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Points:**
|
||||||
|
|
||||||
|
- Always use compound structure - don't flatten to props
|
||||||
|
- Subcomponents are accessed via dot notation (e.g., `Card.Header`)
|
||||||
|
- Each subcomponent may have its own props
|
||||||
|
- **Fetch component docs for complete anatomy and examples**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Semantic Variants
|
||||||
|
|
||||||
|
HeroUI uses semantic naming to communicate functional intent:
|
||||||
|
|
||||||
|
| Variant | Purpose | Usage |
|
||||||
|
| ------------- | --------------------------------- | -------------- |
|
||||||
|
| `primary` | Main action to move forward | 1 per context |
|
||||||
|
| `secondary` | Alternative actions | Multiple |
|
||||||
|
| `tertiary` | Dismissive actions (cancel, skip) | Sparingly |
|
||||||
|
| `danger` | Destructive actions | When needed |
|
||||||
|
| `danger-soft` | Soft destructive actions | Less prominent |
|
||||||
|
| `ghost` | Low-emphasis actions | Minimal weight |
|
||||||
|
| `outline` | Secondary actions | Bordered style |
|
||||||
|
|
||||||
|
**Don't use raw colors** - semantic variants adapt to themes and accessibility.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Theming
|
||||||
|
|
||||||
|
HeroUI Native uses CSS variables via Tailwind/Uniwind for theming. Theme colors are defined in `global.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@theme {
|
||||||
|
--color-accent: hsl(260, 100%, 70%);
|
||||||
|
--color-accent-foreground: hsl(0, 0%, 100%);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Get current theme variables:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/get_theme.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Access theme colors programmatically:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useThemeColor } from "heroui-native";
|
||||||
|
|
||||||
|
const accentColor = useThemeColor("accent");
|
||||||
|
```
|
||||||
|
|
||||||
|
**Theme switching (Light/Dark Mode):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Uniwind, useUniwind } from "uniwind";
|
||||||
|
|
||||||
|
const { theme } = useUniwind();
|
||||||
|
Uniwind.setTheme(theme === "light" ? "dark" : "light");
|
||||||
|
```
|
||||||
|
|
||||||
|
For detailed theming, fetch: `https://v3.heroui.com/docs/native/getting-started/theming.mdx`
|
||||||
157
.agents/skills/heroui-native/scripts/get_component_docs.mjs
Executable file
157
.agents/skills/heroui-native/scripts/get_component_docs.mjs
Executable file
@@ -0,0 +1,157 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Get complete component documentation (MDX) for HeroUI Native components.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node get_component_docs.mjs Button
|
||||||
|
* node get_component_docs.mjs Button Card TextField
|
||||||
|
*
|
||||||
|
* Output:
|
||||||
|
* MDX documentation including imports, usage, variants, props, examples
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE = process.env.HEROUI_NATIVE_API_BASE || "https://native-mcp-api.heroui.com";
|
||||||
|
const FALLBACK_BASE = "https://v3.heroui.com";
|
||||||
|
const APP_PARAM = "app=native-skills";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert PascalCase to kebab-case.
|
||||||
|
*/
|
||||||
|
function toKebabCase(name) {
|
||||||
|
return name
|
||||||
|
.replace(/([a-z])([A-Z])/g, "$1-$2")
|
||||||
|
.replace(/([A-Z])([A-Z][a-z])/g, "$1-$2")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch data from HeroUI Native API with app parameter for analytics.
|
||||||
|
*/
|
||||||
|
async function fetchApi(endpoint, method = "GET", body = null) {
|
||||||
|
const separator = endpoint.includes("?") ? "&" : "?";
|
||||||
|
const url = `${API_BASE}${endpoint}${separator}${APP_PARAM}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const options = {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "HeroUI-Native-Skill/1.0",
|
||||||
|
},
|
||||||
|
method,
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
options.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch MDX directly from v3.heroui.com as fallback.
|
||||||
|
*/
|
||||||
|
async function fetchFallback(component) {
|
||||||
|
const kebabName = toKebabCase(component);
|
||||||
|
const url = `${FALLBACK_BASE}/docs/native/components/${kebabName}.mdx`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { "User-Agent": "HeroUI-Native-Skill/1.0" },
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { component, error: `Failed to fetch docs for ${component}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await response.text();
|
||||||
|
|
||||||
|
return {
|
||||||
|
component,
|
||||||
|
content,
|
||||||
|
contentType: "mdx",
|
||||||
|
source: "fallback",
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { component, error: `Failed to fetch docs for ${component}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main function to get component documentation.
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (args.length === 0) {
|
||||||
|
console.error("Usage: node get_component_docs.mjs <Component1> [Component2] ...");
|
||||||
|
console.error("Example: node get_component_docs.mjs Button Card");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const components = args;
|
||||||
|
|
||||||
|
// Try API first - use POST /v1/components/docs for batch requests
|
||||||
|
console.error(`# Fetching Native docs for: ${components.join(", ")}...`);
|
||||||
|
const data = await fetchApi("/v1/components/docs", "POST", { components });
|
||||||
|
|
||||||
|
if (data && data.results) {
|
||||||
|
// Output results
|
||||||
|
if (data.results.length === 1) {
|
||||||
|
// Single component - output content directly for easier reading
|
||||||
|
const result = data.results[0];
|
||||||
|
|
||||||
|
if (result.content) {
|
||||||
|
console.log(result.content);
|
||||||
|
} else if (result.error) {
|
||||||
|
console.error(`# Error for ${result.component}: ${result.error}`);
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
} else {
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Multiple components - output as JSON array
|
||||||
|
console.log(JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to individual component fetches
|
||||||
|
console.error("# API failed, using fallback...");
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const component of components) {
|
||||||
|
const result = await fetchFallback(component);
|
||||||
|
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output results
|
||||||
|
if (results.length === 1) {
|
||||||
|
// Single component - output content directly for easier reading
|
||||||
|
const result = results[0];
|
||||||
|
|
||||||
|
if (result.content) {
|
||||||
|
console.log(result.content);
|
||||||
|
} else {
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Multiple components - output as JSON array
|
||||||
|
console.log(JSON.stringify(results, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
154
.agents/skills/heroui-native/scripts/get_docs.mjs
Executable file
154
.agents/skills/heroui-native/scripts/get_docs.mjs
Executable file
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Get non-component HeroUI Native documentation (guides, theming, releases).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node get_docs.mjs /docs/native/getting-started/theming
|
||||||
|
* node get_docs.mjs /docs/native/releases/beta-12
|
||||||
|
*
|
||||||
|
* Output:
|
||||||
|
* MDX documentation content
|
||||||
|
*
|
||||||
|
* Note: For component docs, use get_component_docs.mjs instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE = process.env.HEROUI_NATIVE_API_BASE || "https://native-mcp-api.heroui.com";
|
||||||
|
const FALLBACK_BASE = "https://v3.heroui.com";
|
||||||
|
const APP_PARAM = "app=native-skills";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch documentation from HeroUI Native API.
|
||||||
|
* Uses v1 endpoint pattern: /v1/docs/:path
|
||||||
|
*/
|
||||||
|
async function fetchApi(path) {
|
||||||
|
// The v1 API expects path without /docs/ prefix
|
||||||
|
// Input: /docs/native/getting-started/theming
|
||||||
|
// API expects: native/getting-started/theming (route is /v1/docs/:path(*))
|
||||||
|
let apiPath = path.startsWith("/docs/")
|
||||||
|
? path.slice(6) // Remove /docs/ prefix
|
||||||
|
: path.startsWith("/")
|
||||||
|
? path.slice(1) // Remove leading /
|
||||||
|
: path;
|
||||||
|
|
||||||
|
const separator = "?";
|
||||||
|
const url = `${API_BASE}/v1/docs/${apiPath}${separator}${APP_PARAM}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { "User-Agent": "HeroUI-Native-Skill/1.0" },
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`# API Error: HTTP ${response.status}`);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`# API Error: ${error.message}`);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch MDX directly from v3.heroui.com as fallback.
|
||||||
|
*/
|
||||||
|
async function fetchFallback(path) {
|
||||||
|
// Ensure path starts with /docs and ends with .mdx
|
||||||
|
let cleanPath = path.replace(/^\//, "");
|
||||||
|
|
||||||
|
if (!cleanPath.endsWith(".mdx")) {
|
||||||
|
cleanPath = `${cleanPath}.mdx`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${FALLBACK_BASE}/${cleanPath}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { "User-Agent": "HeroUI-Native-Skill/1.0" },
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { error: `HTTP ${response.status}: ${response.statusText}`, path };
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await response.text();
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
contentType: "mdx",
|
||||||
|
path,
|
||||||
|
source: "fallback",
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { error: `Fetch Error: ${error.message}`, path };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main function to get documentation for specified path.
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (args.length === 0) {
|
||||||
|
console.error("Usage: node get_docs.mjs <path>");
|
||||||
|
console.error("Example: node get_docs.mjs /docs/native/getting-started/theming");
|
||||||
|
console.error();
|
||||||
|
console.error("Available paths include:");
|
||||||
|
console.error(" /docs/native/getting-started/theming");
|
||||||
|
console.error(" /docs/native/getting-started/colors");
|
||||||
|
console.error(" /docs/native/getting-started/styling");
|
||||||
|
console.error(" /docs/native/releases/beta-12");
|
||||||
|
console.error();
|
||||||
|
console.error("Note: For component docs, use get_component_docs.mjs instead.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = args[0];
|
||||||
|
|
||||||
|
// Check if user is trying to get component docs
|
||||||
|
if (path.includes("/components/")) {
|
||||||
|
console.error("# Warning: Use get_component_docs.mjs for component documentation.");
|
||||||
|
const componentName = path.split("/").pop().replace(".mdx", "");
|
||||||
|
const titleCase = componentName.charAt(0).toUpperCase() + componentName.slice(1);
|
||||||
|
|
||||||
|
console.error(`# Example: node get_component_docs.mjs ${titleCase}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate Native path
|
||||||
|
if (!path.startsWith("/docs/native/")) {
|
||||||
|
console.error("# Warning: Native documentation paths should start with /docs/native/");
|
||||||
|
console.error(`# Provided path: ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`# Fetching Native documentation for ${path}...`);
|
||||||
|
|
||||||
|
// Try API first
|
||||||
|
const data = await fetchApi(path);
|
||||||
|
|
||||||
|
if (data && data.content) {
|
||||||
|
data.source = "api";
|
||||||
|
console.log(data.content);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to direct fetch
|
||||||
|
console.error("# API failed, using fallback...");
|
||||||
|
const fallbackData = await fetchFallback(path);
|
||||||
|
|
||||||
|
if (fallbackData.content) {
|
||||||
|
console.log(fallbackData.content);
|
||||||
|
} else {
|
||||||
|
console.log(JSON.stringify(fallbackData, null, 2));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
222
.agents/skills/heroui-native/scripts/get_theme.mjs
Executable file
222
.agents/skills/heroui-native/scripts/get_theme.mjs
Executable file
@@ -0,0 +1,222 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Get theme variables and design tokens for HeroUI Native.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node get_theme.mjs
|
||||||
|
*
|
||||||
|
* Output:
|
||||||
|
* Theme variables organized by light/dark with HSL color format
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE = process.env.HEROUI_NATIVE_API_BASE || "https://native-mcp-api.heroui.com";
|
||||||
|
const APP_PARAM = "app=native-skills";
|
||||||
|
|
||||||
|
// Fallback theme reference when API is unavailable
|
||||||
|
const FALLBACK_THEME = {
|
||||||
|
borderRadius: {
|
||||||
|
full: 9999,
|
||||||
|
lg: 12,
|
||||||
|
md: 8,
|
||||||
|
sm: 6,
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
colors: [
|
||||||
|
{
|
||||||
|
category: "base",
|
||||||
|
name: "--color-background",
|
||||||
|
value: "hsl(0, 0%, 14.5%)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "semantic",
|
||||||
|
name: "--color-foreground",
|
||||||
|
value: "hsl(0, 0%, 98.4%)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "semantic",
|
||||||
|
name: "--color-accent",
|
||||||
|
value: "hsl(264.1, 100%, 55.1%)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "status",
|
||||||
|
name: "--color-danger",
|
||||||
|
value: "hsl(25.3, 100%, 63.7%)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "status",
|
||||||
|
name: "--color-success",
|
||||||
|
value: "hsl(163.2, 100%, 76.5%)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "status",
|
||||||
|
name: "--color-warning",
|
||||||
|
value: "hsl(86.0, 100%, 79.5%)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
latestVersion: "beta",
|
||||||
|
light: {
|
||||||
|
colors: [
|
||||||
|
{
|
||||||
|
category: "base",
|
||||||
|
name: "--color-background",
|
||||||
|
value: "hsl(0, 0%, 100%)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "semantic",
|
||||||
|
name: "--color-foreground",
|
||||||
|
value: "hsl(285.89, 5.9%, 21.03%)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "semantic",
|
||||||
|
name: "--color-accent",
|
||||||
|
value: "hsl(253.83, 100%, 62.04%)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "status",
|
||||||
|
name: "--color-danger",
|
||||||
|
value: "hsl(25.74, 100%, 65.32%)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "status",
|
||||||
|
name: "--color-success",
|
||||||
|
value: "hsl(150.81, 100%, 73.29%)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "status",
|
||||||
|
name: "--color-warning",
|
||||||
|
value: "hsl(72.33, 100%, 78.19%)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
note: "This is a fallback. For complete theme variables, ensure the API is accessible.",
|
||||||
|
opacity: {
|
||||||
|
disabled: 0.4,
|
||||||
|
hover: 0.8,
|
||||||
|
pressed: 0.6,
|
||||||
|
},
|
||||||
|
source: "fallback",
|
||||||
|
theme: "default",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch data from HeroUI Native API with app parameter for analytics.
|
||||||
|
*/
|
||||||
|
async function fetchApi(endpoint) {
|
||||||
|
const separator = endpoint.includes("?") ? "&" : "?";
|
||||||
|
const url = `${API_BASE}${endpoint}${separator}${APP_PARAM}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { "User-Agent": "HeroUI-Native-Skill/1.0" },
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`# API Error: HTTP ${response.status}`);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`# API Error: ${error.message}`);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format colors grouped by category.
|
||||||
|
*/
|
||||||
|
function formatColors(colors) {
|
||||||
|
const grouped = {};
|
||||||
|
|
||||||
|
for (const color of colors) {
|
||||||
|
const category = color.category || "semantic";
|
||||||
|
|
||||||
|
if (!grouped[category]) {
|
||||||
|
grouped[category] = [];
|
||||||
|
}
|
||||||
|
grouped[category].push(color);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
for (const [category, tokens] of Object.entries(grouped)) {
|
||||||
|
lines.push(` /* ${category.charAt(0).toUpperCase() + category.slice(1)} Colors */`);
|
||||||
|
for (const token of tokens) {
|
||||||
|
const name = token.name || "";
|
||||||
|
const value = token.value || "";
|
||||||
|
|
||||||
|
lines.push(` ${name}: ${value};`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main function to get theme variables.
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
console.error("# Fetching Native theme variables...");
|
||||||
|
|
||||||
|
const rawData = await fetchApi("/v1/themes/variables?theme=default");
|
||||||
|
|
||||||
|
let data;
|
||||||
|
let version;
|
||||||
|
|
||||||
|
if (!rawData) {
|
||||||
|
console.error("# API failed, using fallback theme reference...");
|
||||||
|
data = FALLBACK_THEME;
|
||||||
|
version = FALLBACK_THEME.latestVersion || "unknown";
|
||||||
|
} else {
|
||||||
|
// Handle API response format
|
||||||
|
data = rawData;
|
||||||
|
version = rawData.latestVersion || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output as formatted structure for readability
|
||||||
|
console.log("/* HeroUI Native Theme Variables */");
|
||||||
|
console.log(`/* Theme: ${data.theme || "default"} */`);
|
||||||
|
console.log(`/* Version: ${version} */`);
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
// Light mode colors
|
||||||
|
if (data.light && data.light.colors) {
|
||||||
|
console.log("/* Light Mode Colors */");
|
||||||
|
console.log(formatColors(data.light.colors));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode colors
|
||||||
|
if (data.dark && data.dark.colors) {
|
||||||
|
console.log("/* Dark Mode Colors */");
|
||||||
|
console.log(formatColors(data.dark.colors));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Border radius
|
||||||
|
if (data.borderRadius) {
|
||||||
|
console.log("/* Border Radius */");
|
||||||
|
for (const [key, value] of Object.entries(data.borderRadius)) {
|
||||||
|
console.log(` --radius-${key}: ${value};`);
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opacity
|
||||||
|
if (data.opacity) {
|
||||||
|
console.log("/* Opacity */");
|
||||||
|
for (const [key, value] of Object.entries(data.opacity)) {
|
||||||
|
console.log(` --opacity-${key}: ${value};`);
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also output raw JSON to stderr for programmatic use
|
||||||
|
console.error("\n# Raw JSON output:");
|
||||||
|
console.error(JSON.stringify(rawData || data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
134
.agents/skills/heroui-native/scripts/list_components.mjs
Executable file
134
.agents/skills/heroui-native/scripts/list_components.mjs
Executable file
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* List all available HeroUI Native components.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node list_components.mjs
|
||||||
|
*
|
||||||
|
* Output:
|
||||||
|
* JSON with components array, latestVersion, and count
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE = process.env.HEROUI_NATIVE_API_BASE || "https://native-mcp-api.heroui.com";
|
||||||
|
const APP_PARAM = "app=native-skills";
|
||||||
|
const LLMS_TXT_URL = "https://v3.heroui.com/native/llms.txt";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch data from HeroUI Native API with app parameter for analytics.
|
||||||
|
*/
|
||||||
|
async function fetchApi(endpoint) {
|
||||||
|
const separator = endpoint.includes("?") ? "&" : "?";
|
||||||
|
const url = `${API_BASE}${endpoint}${separator}${APP_PARAM}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { "User-Agent": "HeroUI-Native-Skill/1.0" },
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`HTTP Error ${response.status}: ${response.statusText}`);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`API Error: ${error.message}`);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch component list from llms.txt fallback URL.
|
||||||
|
*/
|
||||||
|
async function fetchFallback() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(LLMS_TXT_URL, {
|
||||||
|
headers: { "User-Agent": "HeroUI-Native-Skill/1.0" },
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await response.text();
|
||||||
|
|
||||||
|
// Parse markdown to extract component names from pattern: - [ComponentName](url)
|
||||||
|
// Look for links under the Components section (### Components)
|
||||||
|
const components = [];
|
||||||
|
let inComponentsSection = false;
|
||||||
|
|
||||||
|
for (const line of content.split("\n")) {
|
||||||
|
// Check if we're entering the Components section (uses ### header)
|
||||||
|
if (line.trim() === "### Components") {
|
||||||
|
inComponentsSection = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're leaving the Components section (another ### header)
|
||||||
|
if (inComponentsSection && line.trim().startsWith("### ")) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract component name from markdown link pattern
|
||||||
|
// Match: - [ComponentName](https://v3.heroui.com/docs/native/components/component-name)
|
||||||
|
// Skip "All Components" which links to /components without a specific component
|
||||||
|
if (inComponentsSection) {
|
||||||
|
const match = line.match(
|
||||||
|
/^\s*-\s*\[([^\]]+)\]\(https:\/\/v3\.heroui\.com\/docs\/native\/components\/[a-z]/,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
components.push(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (components.length > 0) {
|
||||||
|
console.error(`# Using fallback: ${LLMS_TXT_URL}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
components: components.sort(),
|
||||||
|
count: components.length,
|
||||||
|
latestVersion: "unknown",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Fallback Error: ${error.message}`);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main function to list all available HeroUI Native components.
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
let data = await fetchApi("/v1/components");
|
||||||
|
|
||||||
|
// Check if API returned valid data with components
|
||||||
|
if (!data || !data.components || data.components.length === 0) {
|
||||||
|
console.error("# API returned no components, trying fallback...");
|
||||||
|
data = await fetchFallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || !data.components || data.components.length === 0) {
|
||||||
|
console.error("Error: Failed to fetch component list from API and fallback");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output formatted JSON
|
||||||
|
console.log(JSON.stringify(data, null, 2));
|
||||||
|
|
||||||
|
// Print summary to stderr for human readability
|
||||||
|
console.error(
|
||||||
|
`\n# Found ${data.components.length} Native components (${data.latestVersion || "unknown"})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
90
.agents/skills/hono/SKILL.md
Normal file
90
.agents/skills/hono/SKILL.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
name: hono
|
||||||
|
description: Efficiently develop Hono applications using Hono CLI. Supports documentation search, API reference lookup, request testing, and bundle optimization.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Hono Skill
|
||||||
|
|
||||||
|
Develop Hono applications efficiently using Hono CLI (`@hono/cli`).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
You can use Hono CLI without global installation via npx:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @hono/cli <command>
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install globally (optional):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g @hono/cli
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands for AI
|
||||||
|
|
||||||
|
### 1. Search Documentation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hono search "<query>" --pretty
|
||||||
|
```
|
||||||
|
|
||||||
|
Search for Hono APIs and features. Use `--pretty` for human-readable output.
|
||||||
|
|
||||||
|
### 2. View Documentation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hono docs [path]
|
||||||
|
```
|
||||||
|
|
||||||
|
Display detailed documentation for a specific path found in search results.
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hono docs /docs/api/context
|
||||||
|
hono docs /docs/api/hono
|
||||||
|
hono docs /docs/helpers/factory
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Request Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# GET request
|
||||||
|
hono request [file] -P /path
|
||||||
|
|
||||||
|
# POST request
|
||||||
|
hono request [file] -X POST -P /api/users -d '{"name": "test"}'
|
||||||
|
|
||||||
|
# Request with headers
|
||||||
|
hono request [file] -H "Authorization: Bearer token" -P /api/protected
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses `app.request()` internally, so no server startup required for testing.
|
||||||
|
|
||||||
|
### 4. Optimization & Bundling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Bundle optimization
|
||||||
|
hono optimize [entry] -o dist/index.js
|
||||||
|
|
||||||
|
# With minification
|
||||||
|
hono optimize [entry] -o dist/index.js --minify
|
||||||
|
|
||||||
|
# Specify target (cloudflare-workers, deno, etc.)
|
||||||
|
hono optimize [entry] -t cloudflare-workers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
1. **Research**: Use `hono search` → `hono docs` to investigate APIs and features
|
||||||
|
2. **Implement**: Write the code
|
||||||
|
3. **Test**: Use `hono request` to test endpoints
|
||||||
|
4. **Optimize**: Use `hono optimize` for production builds when needed
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
- Always search with `hono search` before implementing unfamiliar APIs
|
||||||
|
- Use `--pretty` flag with `hono search` (default output is JSON)
|
||||||
|
- `hono request` works without starting an HTTP server
|
||||||
|
- Search for middleware usage with `hono search "middleware name"`
|
||||||
503
.agents/skills/native-data-fetching/SKILL.md
Normal file
503
.agents/skills/native-data-fetching/SKILL.md
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
---
|
||||||
|
name: native-data-fetching
|
||||||
|
description: Use when implementing or debugging ANY network request, API call, or data fetching. Covers fetch API, React Query, SWR, error handling, caching, offline support, and Expo Router data loaders (useLoaderData).
|
||||||
|
version: 1.0.0
|
||||||
|
license: MIT
|
||||||
|
---
|
||||||
|
|
||||||
|
# Expo Networking
|
||||||
|
|
||||||
|
**You MUST use this skill for ANY networking work including API requests, data fetching, caching, or network debugging.**
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
Consult these resources as needed:
|
||||||
|
|
||||||
|
```
|
||||||
|
references/
|
||||||
|
expo-router-loaders.md Route-level data loading with Expo Router loaders (web, SDK 55+)
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
Use this skill when:
|
||||||
|
|
||||||
|
- Implementing API requests
|
||||||
|
- Setting up data fetching (React Query, SWR)
|
||||||
|
- Using Expo Router data loaders (`useLoaderData`, web SDK 55+)
|
||||||
|
- Debugging network failures
|
||||||
|
- Implementing caching strategies
|
||||||
|
- Handling offline scenarios
|
||||||
|
- Authentication/token management
|
||||||
|
- Configuring API URLs and environment variables
|
||||||
|
|
||||||
|
## Preferences
|
||||||
|
|
||||||
|
- Avoid axios, prefer expo/fetch
|
||||||
|
|
||||||
|
## Common Issues & Solutions
|
||||||
|
|
||||||
|
### 1. Basic Fetch Usage
|
||||||
|
|
||||||
|
**Simple GET request**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const fetchUser = async (userId: string) => {
|
||||||
|
const response = await fetch(`https://api.example.com/users/${userId}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**POST request with body**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const createUser = async (userData: UserData) => {
|
||||||
|
const response = await fetch("https://api.example.com/users", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(userData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. React Query (TanStack Query)
|
||||||
|
|
||||||
|
**Setup**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/_layout.tsx
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
retry: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Stack />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fetching data**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
function UserProfile({ userId }: { userId: string }) {
|
||||||
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: ["user", userId],
|
||||||
|
queryFn: () => fetchUser(userId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <Loading />;
|
||||||
|
if (error) return <Error message={error.message} />;
|
||||||
|
|
||||||
|
return <Profile user={data} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mutations**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
function CreateUserForm() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: createUser,
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate and refetch
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (data: UserData) => {
|
||||||
|
mutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Form onSubmit={handleSubmit} isLoading={mutation.isPending} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Error Handling
|
||||||
|
|
||||||
|
**Comprehensive error handling**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public status: number,
|
||||||
|
public code?: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchWithErrorHandling = async (url: string, options?: RequestInit) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({}));
|
||||||
|
throw new ApiError(error.message || "Request failed", response.status, error.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// Network error (no internet, timeout, etc.)
|
||||||
|
throw new ApiError("Network error", 0, "NETWORK_ERROR");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Retry logic**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const fetchWithRetry = async (url: string, options?: RequestInit, retries = 3) => {
|
||||||
|
for (let i = 0; i < retries; i++) {
|
||||||
|
try {
|
||||||
|
return await fetchWithErrorHandling(url, options);
|
||||||
|
} catch (error) {
|
||||||
|
if (i === retries - 1) throw error;
|
||||||
|
// Exponential backoff
|
||||||
|
await new Promise((r) => setTimeout(r, Math.pow(2, i) * 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Authentication
|
||||||
|
|
||||||
|
**Token management**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import * as SecureStore from "expo-secure-store";
|
||||||
|
|
||||||
|
const TOKEN_KEY = "auth_token";
|
||||||
|
|
||||||
|
export const auth = {
|
||||||
|
getToken: () => SecureStore.getItemAsync(TOKEN_KEY),
|
||||||
|
setToken: (token: string) => SecureStore.setItemAsync(TOKEN_KEY, token),
|
||||||
|
removeToken: () => SecureStore.deleteItemAsync(TOKEN_KEY),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Authenticated fetch wrapper
|
||||||
|
const authFetch = async (url: string, options: RequestInit = {}) => {
|
||||||
|
const token = await auth.getToken();
|
||||||
|
|
||||||
|
return fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...options.headers,
|
||||||
|
Authorization: token ? `Bearer ${token}` : "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Token refresh**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
let isRefreshing = false;
|
||||||
|
let refreshPromise: Promise<string> | null = null;
|
||||||
|
|
||||||
|
const getValidToken = async (): Promise<string> => {
|
||||||
|
const token = await auth.getToken();
|
||||||
|
|
||||||
|
if (!token || isTokenExpired(token)) {
|
||||||
|
if (!isRefreshing) {
|
||||||
|
isRefreshing = true;
|
||||||
|
refreshPromise = refreshToken().finally(() => {
|
||||||
|
isRefreshing = false;
|
||||||
|
refreshPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return refreshPromise!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Offline Support
|
||||||
|
|
||||||
|
**Check network status**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
|
|
||||||
|
// Hook for network status
|
||||||
|
function useNetworkStatus() {
|
||||||
|
const [isOnline, setIsOnline] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return NetInfo.addEventListener((state) => {
|
||||||
|
setIsOnline(state.isConnected ?? true);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isOnline;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Offline-first with React Query**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { onlineManager } from "@tanstack/react-query";
|
||||||
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
|
|
||||||
|
// Sync React Query with network status
|
||||||
|
onlineManager.setEventListener((setOnline) => {
|
||||||
|
return NetInfo.addEventListener((state) => {
|
||||||
|
setOnline(state.isConnected ?? true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Queries will pause when offline and resume when online
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Environment Variables
|
||||||
|
|
||||||
|
**Using environment variables for API configuration**:
|
||||||
|
|
||||||
|
Expo supports environment variables with the `EXPO_PUBLIC_` prefix. These are inlined at build time and available in your JavaScript code.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// .env
|
||||||
|
EXPO_PUBLIC_API_URL=https://api.example.com
|
||||||
|
EXPO_PUBLIC_API_VERSION=v1
|
||||||
|
|
||||||
|
// Usage in code
|
||||||
|
const API_URL = process.env.EXPO_PUBLIC_API_URL;
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
const response = await fetch(`${API_URL}/users`);
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Environment-specific configuration**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// .env.development
|
||||||
|
EXPO_PUBLIC_API_URL=http://localhost:3000
|
||||||
|
|
||||||
|
// .env.production
|
||||||
|
EXPO_PUBLIC_API_URL=https://api.production.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**Creating an API client with environment config**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// api/client.ts
|
||||||
|
const BASE_URL = process.env.EXPO_PUBLIC_API_URL;
|
||||||
|
|
||||||
|
if (!BASE_URL) {
|
||||||
|
throw new Error("EXPO_PUBLIC_API_URL is not defined");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiClient = {
|
||||||
|
get: async <T,>(path: string): Promise<T> => {
|
||||||
|
const response = await fetch(`${BASE_URL}${path}`);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
post: async <T,>(path: string, body: unknown): Promise<T> => {
|
||||||
|
const response = await fetch(`${BASE_URL}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important notes**:
|
||||||
|
|
||||||
|
- Only variables prefixed with `EXPO_PUBLIC_` are exposed to the client bundle
|
||||||
|
- Never put secrets (API keys with write access, database passwords) in `EXPO_PUBLIC_` variables—they're visible in the built app
|
||||||
|
- Environment variables are inlined at **build time**, not runtime
|
||||||
|
- Restart the dev server after changing `.env` files
|
||||||
|
- For server-side secrets in API routes, use variables without the `EXPO_PUBLIC_` prefix
|
||||||
|
|
||||||
|
**TypeScript support**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// types/env.d.ts
|
||||||
|
declare global {
|
||||||
|
namespace NodeJS {
|
||||||
|
interface ProcessEnv {
|
||||||
|
EXPO_PUBLIC_API_URL: string;
|
||||||
|
EXPO_PUBLIC_API_VERSION?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Request Cancellation
|
||||||
|
|
||||||
|
**Cancel on unmount**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
fetch(url, { signal: controller.signal })
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then(setData)
|
||||||
|
.catch((error) => {
|
||||||
|
if (error.name !== "AbortError") {
|
||||||
|
setError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [url]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**With React Query** (automatic):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// React Query automatically cancels requests when queries are invalidated
|
||||||
|
// or components unmount
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
User asks about networking
|
||||||
|
|-- Route-level data loading (web, SDK 55+)?
|
||||||
|
| \-- Expo Router loaders — see references/expo-router-loaders.md
|
||||||
|
|
|
||||||
|
|-- Basic fetch?
|
||||||
|
| \-- Use fetch API with error handling
|
||||||
|
|
|
||||||
|
|-- Need caching/state management?
|
||||||
|
| |-- Complex app -> React Query (TanStack Query)
|
||||||
|
| \-- Simpler needs -> SWR or custom hooks
|
||||||
|
|
|
||||||
|
|-- Authentication?
|
||||||
|
| |-- Token storage -> expo-secure-store
|
||||||
|
| \-- Token refresh -> Implement refresh flow
|
||||||
|
|
|
||||||
|
|-- Error handling?
|
||||||
|
| |-- Network errors -> Check connectivity first
|
||||||
|
| |-- HTTP errors -> Parse response, throw typed errors
|
||||||
|
| \-- Retries -> Exponential backoff
|
||||||
|
|
|
||||||
|
|-- Offline support?
|
||||||
|
| |-- Check status -> NetInfo
|
||||||
|
| \-- Queue requests -> React Query persistence
|
||||||
|
|
|
||||||
|
|-- Environment/API config?
|
||||||
|
| |-- Client-side URLs -> EXPO_PUBLIC_ prefix in .env
|
||||||
|
| |-- Server secrets -> Non-prefixed env vars (API routes only)
|
||||||
|
| \-- Multiple environments -> .env.development, .env.production
|
||||||
|
|
|
||||||
|
\-- Performance?
|
||||||
|
|-- Caching -> React Query with staleTime
|
||||||
|
|-- Deduplication -> React Query handles this
|
||||||
|
\-- Cancellation -> AbortController or React Query
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
**Wrong: No error handling**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const data = await fetch(url).then((r) => r.json());
|
||||||
|
```
|
||||||
|
|
||||||
|
**Right: Check response status**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const data = await response.json();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wrong: Storing tokens in AsyncStorage**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
await AsyncStorage.setItem("token", token); // Not secure!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Right: Use SecureStore for sensitive data**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
await SecureStore.setItemAsync("token", token);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Invocations
|
||||||
|
|
||||||
|
User: "How do I make API calls in React Native?"
|
||||||
|
-> Use fetch, wrap with error handling
|
||||||
|
|
||||||
|
User: "Should I use React Query or SWR?"
|
||||||
|
-> React Query for complex apps, SWR for simpler needs
|
||||||
|
|
||||||
|
User: "My app needs to work offline"
|
||||||
|
-> Use NetInfo for status, React Query persistence for caching
|
||||||
|
|
||||||
|
User: "How do I handle authentication tokens?"
|
||||||
|
-> Store in expo-secure-store, implement refresh flow
|
||||||
|
|
||||||
|
User: "API calls are slow"
|
||||||
|
-> Check caching strategy, use React Query staleTime
|
||||||
|
|
||||||
|
User: "How do I configure different API URLs for dev and prod?"
|
||||||
|
-> Use EXPO*PUBLIC* env vars with .env.development and .env.production files
|
||||||
|
|
||||||
|
User: "Where should I put my API key?"
|
||||||
|
-> Client-safe keys: EXPO*PUBLIC* in .env. Secret keys: non-prefixed env vars in API routes only
|
||||||
|
|
||||||
|
User: "How do I load data for a page in Expo Router?"
|
||||||
|
-> See references/expo-router-loaders.md for route-level loaders (web, SDK 55+). For native, use React Query or fetch.
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
# Expo Router Data Loaders
|
||||||
|
|
||||||
|
Route-level data loading for web apps using Expo SDK 55+. Loaders are async functions exported from route files that load data before the route renders, following the Remix/React Router loader model.
|
||||||
|
|
||||||
|
**Dual execution model:**
|
||||||
|
|
||||||
|
- **Initial page load (SSR):** The loader runs server-side. Its return value is serialized as JSON and embedded in the HTML response.
|
||||||
|
- **Client-side navigation:** The browser fetches the loader data from the server via HTTP. The route renders once the data arrives.
|
||||||
|
|
||||||
|
You write one function and the framework manages when and how it executes.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
**Requirements:** Expo SDK 55+, web output mode (`npx expo serve` or `npx expo export --platform web`) set in `app.json` or `app.config.js`.
|
||||||
|
|
||||||
|
**Server rendering:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"web": {
|
||||||
|
"output": "server"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
[
|
||||||
|
"expo-router",
|
||||||
|
{
|
||||||
|
"unstable_useServerDataLoaders": true,
|
||||||
|
"unstable_useServerRendering": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Static/SSG:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"web": {
|
||||||
|
"output": "static"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
[
|
||||||
|
"expo-router",
|
||||||
|
{
|
||||||
|
"unstable_useServerDataLoaders": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| | `"server"` | `"static"` |
|
||||||
|
| ------------------------------- | ------------------------------ | ------------------------------------- |
|
||||||
|
| `unstable_useServerDataLoaders` | Required | Required |
|
||||||
|
| `unstable_useServerRendering` | Required | Not required |
|
||||||
|
| Loader runs on | Live server (every request) | Build time (static generation) |
|
||||||
|
| `request` object | Full access (headers, cookies) | Not available |
|
||||||
|
| Hosting | Node.js server (EAS Hosting) | Any static host (Netlify, Vercel, S3) |
|
||||||
|
|
||||||
|
## Imports
|
||||||
|
|
||||||
|
Loaders use two packages:
|
||||||
|
|
||||||
|
- **`expo-router`** — `useLoaderData` hook
|
||||||
|
- **`expo-server`** — `LoaderFunction` type, `StatusError`, `setResponseHeaders`. Always available (dependency of `expo-router`), no install needed.
|
||||||
|
|
||||||
|
## Basic Loader
|
||||||
|
|
||||||
|
For loaders without params, a plain async function works:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/posts/index.tsx
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { useLoaderData } from "expo-router";
|
||||||
|
import { ActivityIndicator, View, Text } from "react-native";
|
||||||
|
|
||||||
|
export async function loader() {
|
||||||
|
const response = await fetch("https://api.example.com/posts");
|
||||||
|
const posts = await response.json();
|
||||||
|
return { posts };
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostList() {
|
||||||
|
const { posts } = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
{posts.map((post) => (
|
||||||
|
<Text key={post.id}>{post.title}</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Posts() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<ActivityIndicator size="large" />}>
|
||||||
|
<PostList />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`useLoaderData` is typed via `typeof loader` — the generic parameter infers the return type.
|
||||||
|
|
||||||
|
## Dynamic Routes
|
||||||
|
|
||||||
|
For loaders with params, use the `LoaderFunction<T>` type from `expo-server`. The first argument is the request (an immutable `Request`-like object, or `undefined` in static mode). The second is `params` (`Record<string, string | string[]>`), which contains **path parameters only**. Access individual params with a cast like `params.id as string`. For query parameters, use `new URL(request.url).searchParams`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/posts/[id].tsx
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { useLoaderData } from "expo-router";
|
||||||
|
import { StatusError, type LoaderFunction } from "expo-server";
|
||||||
|
import { ActivityIndicator, View, Text } from "react-native";
|
||||||
|
|
||||||
|
type Post = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loader: LoaderFunction<{ post: Post }> = async (request, params) => {
|
||||||
|
const id = params.id as string;
|
||||||
|
const response = await fetch(`https://api.example.com/posts/${id}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new StatusError(404, `Post ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const post: Post = await response.json();
|
||||||
|
return { post };
|
||||||
|
};
|
||||||
|
|
||||||
|
function PostContent() {
|
||||||
|
const { post } = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text>{post.title}</Text>
|
||||||
|
<Text>{post.body}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PostDetail() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<ActivityIndicator size="large" />}>
|
||||||
|
<PostContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Catch-all routes access `params.slug` the same way:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/docs/[...slug].tsx
|
||||||
|
import { type LoaderFunction } from "expo-server";
|
||||||
|
|
||||||
|
type Doc = { title: string; content: string };
|
||||||
|
|
||||||
|
export const loader: LoaderFunction<{ doc: Doc }> = async (request, params) => {
|
||||||
|
const slug = params.slug as string[];
|
||||||
|
const path = slug.join("/");
|
||||||
|
const doc = await fetchDoc(path);
|
||||||
|
return { doc };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Query parameters are available via the `request` object (server output mode only):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/search.tsx
|
||||||
|
import { type LoaderFunction } from "expo-server";
|
||||||
|
|
||||||
|
export const loader: LoaderFunction<{ results: any[]; query: string }> = async (request) => {
|
||||||
|
// Assuming request.url is `/search?q=expo&page=2`
|
||||||
|
const url = new URL(request!.url);
|
||||||
|
const query = url.searchParams.get("q") ?? "";
|
||||||
|
const page = Number(url.searchParams.get("page") ?? "1");
|
||||||
|
|
||||||
|
const results = await fetchSearchResults(query, page);
|
||||||
|
return { results, query };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server-Side Secrets & Request Access
|
||||||
|
|
||||||
|
Loaders run on the server, so you can access secrets and server-only resources directly:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/dashboard.tsx
|
||||||
|
import { type LoaderFunction } from "expo-server";
|
||||||
|
|
||||||
|
export const loader: LoaderFunction<{ balance: any; isAuthenticated: boolean }> = async (
|
||||||
|
request,
|
||||||
|
params,
|
||||||
|
) => {
|
||||||
|
const data = await fetch("https://api.stripe.com/v1/balance", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionToken = request?.headers.get("cookie")?.match(/session=([^;]+)/)?.[1];
|
||||||
|
|
||||||
|
const balance = await data.json();
|
||||||
|
return { balance, isAuthenticated: !!sessionToken };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The `request` object is available in server output mode. In static output mode, `request` is always `undefined`.
|
||||||
|
|
||||||
|
## Response Utilities
|
||||||
|
|
||||||
|
### Setting Response Headers
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/products.tsx
|
||||||
|
import { setResponseHeaders } from "expo-server";
|
||||||
|
|
||||||
|
export async function loader() {
|
||||||
|
setResponseHeaders({
|
||||||
|
"Cache-Control": "public, max-age=300",
|
||||||
|
});
|
||||||
|
|
||||||
|
const products = await fetchProducts();
|
||||||
|
return { products };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Throwing HTTP Errors
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/products/[id].tsx
|
||||||
|
import { StatusError, type LoaderFunction } from "expo-server";
|
||||||
|
|
||||||
|
export const loader: LoaderFunction<{ product: Product }> = async (request, params) => {
|
||||||
|
const id = params.id as string;
|
||||||
|
const product = await fetchProduct(id);
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
throw new StatusError(404, "Product not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { product };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Suspense & Error Boundaries
|
||||||
|
|
||||||
|
### Loading States with Suspense
|
||||||
|
|
||||||
|
`useLoaderData()` suspends during client-side navigation. Push it into a child component and wrap with `<Suspense>`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/posts/index.tsx
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { useLoaderData } from "expo-router";
|
||||||
|
import { ActivityIndicator, View, Text } from "react-native";
|
||||||
|
|
||||||
|
export async function loader() {
|
||||||
|
const response = await fetch("https://api.example.com/posts");
|
||||||
|
return { posts: await response.json() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostList() {
|
||||||
|
const { posts } = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
{posts.map((post) => (
|
||||||
|
<Text key={post.id}>{post.title}</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Posts() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||||
|
<ActivityIndicator size="large" />
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PostList />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `<Suspense>` boundary must be above the component calling `useLoaderData()`. On initial page load the data is already in the HTML, suspension only occurs during client-side navigation.
|
||||||
|
|
||||||
|
### Error Boundaries
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/posts/[id].tsx
|
||||||
|
export function ErrorBoundary({ error }: { error: Error }) {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||||
|
<Text>Error: {error.message}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When a loader throws (including `StatusError`), the nearest `ErrorBoundary` catches it.
|
||||||
|
|
||||||
|
## Static vs Server Rendering
|
||||||
|
|
||||||
|
| | Server (`"server"`) | Static (`"static"`) |
|
||||||
|
| -------------------- | ------------------------------- | --------------------------------- |
|
||||||
|
| **When loader runs** | Every request (live) | At build time (`npx expo export`) |
|
||||||
|
| **Data freshness** | Fresh on initial server request | Stale until next build |
|
||||||
|
| **`request` object** | Full access | Not available |
|
||||||
|
| **Hosting** | Node.js server (EAS Hosting) | Any static host |
|
||||||
|
| **Use case** | Personalized/dynamic content | Marketing pages, blogs, docs |
|
||||||
|
|
||||||
|
**Choose server** when data changes frequently or content is personalized (cookies, auth, headers).
|
||||||
|
|
||||||
|
**Choose static** when content is the same for all users and changes infrequently.
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Loaders are web-only; use client-side fetching (React Query, fetch) for native
|
||||||
|
- Loaders cannot be used in `_layout` files — only in route files
|
||||||
|
- Use `LoaderFunction<T>` from `expo-server` to type loaders that use params
|
||||||
|
- The request object is immutable — use optional chaining (`request?.headers`) as it may be `undefined` in static mode
|
||||||
|
- Return only JSON-serializable values (no `Date`, `Map`, `Set`, class instances, functions)
|
||||||
|
- Use non-prefixed `process.env` vars for secrets in loaders, not `EXPO_PUBLIC_` (which is embedded in the client bundle)
|
||||||
|
- Use `StatusError` from `expo-server` for HTTP error responses
|
||||||
|
- Use `setResponseHeaders` from `expo-server` to set headers
|
||||||
|
- Export `ErrorBoundary` from route files to handle loader failures gracefully
|
||||||
|
- Validate and sanitize user input (params, query strings) before using in database queries or API calls
|
||||||
|
- Handle errors gracefully with try/catch; log server-side for debugging
|
||||||
|
- Loader data is currently cached for the session. This is a known limitation that will be lifted in a future release
|
||||||
241
.agents/skills/shadcn/SKILL.md
Normal file
241
.agents/skills/shadcn/SKILL.md
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
---
|
||||||
|
name: shadcn
|
||||||
|
description: Manages shadcn components and projects — adding, searching, fixing, debugging, styling, and composing UI. Provides project context, component docs, and usage examples. Applies when working with shadcn/ui, component registries, presets, --preset codes, or any project with a components.json file. Also triggers for "shadcn init", "create an app with --preset", or "switch to --preset".
|
||||||
|
user-invocable: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# shadcn/ui
|
||||||
|
|
||||||
|
A framework for building ui, components and design systems. Components are added as source code to the user's project via the CLI.
|
||||||
|
|
||||||
|
> **IMPORTANT:** Run all CLI commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest` — based on the project's `packageManager`. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
|
||||||
|
|
||||||
|
## Current Project Context
|
||||||
|
|
||||||
|
```json
|
||||||
|
!`npx shadcn@latest info --json 2>/dev/null || echo '{"error": "No shadcn project found. Run shadcn init first."}'`
|
||||||
|
```
|
||||||
|
|
||||||
|
The JSON above contains the project config and installed components. Use `npx shadcn@latest docs <component>` to get documentation and example URLs for any component.
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
1. **Use existing components first.** Use `npx shadcn@latest search` to check registries before writing custom UI. Check community registries too.
|
||||||
|
2. **Compose, don't reinvent.** Settings page = Tabs + Card + form controls. Dashboard = Sidebar + Card + Chart + Table.
|
||||||
|
3. **Use built-in variants before custom styles.** `variant="outline"`, `size="sm"`, etc.
|
||||||
|
4. **Use semantic colors.** `bg-primary`, `text-muted-foreground` — never raw values like `bg-blue-500`.
|
||||||
|
|
||||||
|
## Critical Rules
|
||||||
|
|
||||||
|
These rules are **always enforced**. Each links to a file with Incorrect/Correct code pairs.
|
||||||
|
|
||||||
|
### Styling & Tailwind → [styling.md](./rules/styling.md)
|
||||||
|
|
||||||
|
- **`className` for layout, not styling.** Never override component colors or typography.
|
||||||
|
- **No `space-x-*` or `space-y-*`.** Use `flex` with `gap-*`. For vertical stacks, `flex flex-col gap-*`.
|
||||||
|
- **Use `size-*` when width and height are equal.** `size-10` not `w-10 h-10`.
|
||||||
|
- **Use `truncate` shorthand.** Not `overflow-hidden text-ellipsis whitespace-nowrap`.
|
||||||
|
- **No manual `dark:` color overrides.** Use semantic tokens (`bg-background`, `text-muted-foreground`).
|
||||||
|
- **Use `cn()` for conditional classes.** Don't write manual template literal ternaries.
|
||||||
|
- **No manual `z-index` on overlay components.** Dialog, Sheet, Popover, etc. handle their own stacking.
|
||||||
|
|
||||||
|
### Forms & Inputs → [forms.md](./rules/forms.md)
|
||||||
|
|
||||||
|
- **Forms use `FieldGroup` + `Field`.** Never use raw `div` with `space-y-*` or `grid gap-*` for form layout.
|
||||||
|
- **`InputGroup` uses `InputGroupInput`/`InputGroupTextarea`.** Never raw `Input`/`Textarea` inside `InputGroup`.
|
||||||
|
- **Buttons inside inputs use `InputGroup` + `InputGroupAddon`.**
|
||||||
|
- **Option sets (2–7 choices) use `ToggleGroup`.** Don't loop `Button` with manual active state.
|
||||||
|
- **`FieldSet` + `FieldLegend` for grouping related checkboxes/radios.** Don't use a `div` with a heading.
|
||||||
|
- **Field validation uses `data-invalid` + `aria-invalid`.** `data-invalid` on `Field`, `aria-invalid` on the control. For disabled: `data-disabled` on `Field`, `disabled` on the control.
|
||||||
|
|
||||||
|
### Component Structure → [composition.md](./rules/composition.md)
|
||||||
|
|
||||||
|
- **Items always inside their Group.** `SelectItem` → `SelectGroup`. `DropdownMenuItem` → `DropdownMenuGroup`. `CommandItem` → `CommandGroup`.
|
||||||
|
- **Use `asChild` (radix) or `render` (base) for custom triggers.** Check `base` field from `npx shadcn@latest info`. → [base-vs-radix.md](./rules/base-vs-radix.md)
|
||||||
|
- **Dialog, Sheet, and Drawer always need a Title.** `DialogTitle`, `SheetTitle`, `DrawerTitle` required for accessibility. Use `className="sr-only"` if visually hidden.
|
||||||
|
- **Use full Card composition.** `CardHeader`/`CardTitle`/`CardDescription`/`CardContent`/`CardFooter`. Don't dump everything in `CardContent`.
|
||||||
|
- **Button has no `isPending`/`isLoading`.** Compose with `Spinner` + `data-icon` + `disabled`.
|
||||||
|
- **`TabsTrigger` must be inside `TabsList`.** Never render triggers directly in `Tabs`.
|
||||||
|
- **`Avatar` always needs `AvatarFallback`.** For when the image fails to load.
|
||||||
|
|
||||||
|
### Use Components, Not Custom Markup → [composition.md](./rules/composition.md)
|
||||||
|
|
||||||
|
- **Use existing components before custom markup.** Check if a component exists before writing a styled `div`.
|
||||||
|
- **Callouts use `Alert`.** Don't build custom styled divs.
|
||||||
|
- **Empty states use `Empty`.** Don't build custom empty state markup.
|
||||||
|
- **Toast via `sonner`.** Use `toast()` from `sonner`.
|
||||||
|
- **Use `Separator`** instead of `<hr>` or `<div className="border-t">`.
|
||||||
|
- **Use `Skeleton`** for loading placeholders. No custom `animate-pulse` divs.
|
||||||
|
- **Use `Badge`** instead of custom styled spans.
|
||||||
|
|
||||||
|
### Icons → [icons.md](./rules/icons.md)
|
||||||
|
|
||||||
|
- **Icons in `Button` use `data-icon`.** `data-icon="inline-start"` or `data-icon="inline-end"` on the icon.
|
||||||
|
- **No sizing classes on icons inside components.** Components handle icon sizing via CSS. No `size-4` or `w-4 h-4`.
|
||||||
|
- **Pass icons as objects, not string keys.** `icon={CheckIcon}`, not a string lookup.
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
|
||||||
|
- **Never decode or fetch preset codes manually.** Pass them directly to `npx shadcn@latest init --preset <code>`.
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
These are the most common patterns that differentiate correct shadcn/ui code. For edge cases, see the linked rule files above.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Form layout: FieldGroup + Field, not div + Label.
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||||
|
<Input id="email" />
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
// Validation: data-invalid on Field, aria-invalid on the control.
|
||||||
|
<Field data-invalid>
|
||||||
|
<FieldLabel>Email</FieldLabel>
|
||||||
|
<Input aria-invalid />
|
||||||
|
<FieldDescription>Invalid email.</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
// Icons in buttons: data-icon, no sizing classes.
|
||||||
|
<Button>
|
||||||
|
<SearchIcon data-icon="inline-start" />
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
// Spacing: gap-*, not space-y-*.
|
||||||
|
<div className="flex flex-col gap-4"> // correct
|
||||||
|
<div className="space-y-4"> // wrong
|
||||||
|
|
||||||
|
// Equal dimensions: size-*, not w-* h-*.
|
||||||
|
<Avatar className="size-10"> // correct
|
||||||
|
<Avatar className="w-10 h-10"> // wrong
|
||||||
|
|
||||||
|
// Status colors: Badge variants or semantic tokens, not raw colors.
|
||||||
|
<Badge variant="secondary">+20.1%</Badge> // correct
|
||||||
|
<span className="text-emerald-600">+20.1%</span> // wrong
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Selection
|
||||||
|
|
||||||
|
| Need | Use |
|
||||||
|
| -------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||||
|
| Button/action | `Button` with appropriate variant |
|
||||||
|
| Form inputs | `Input`, `Select`, `Combobox`, `Switch`, `Checkbox`, `RadioGroup`, `Textarea`, `InputOTP`, `Slider` |
|
||||||
|
| Toggle between 2–5 options | `ToggleGroup` + `ToggleGroupItem` |
|
||||||
|
| Data display | `Table`, `Card`, `Badge`, `Avatar` |
|
||||||
|
| Navigation | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination` |
|
||||||
|
| Overlays | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation) |
|
||||||
|
| Feedback | `sonner` (toast), `Alert`, `Progress`, `Skeleton`, `Spinner` |
|
||||||
|
| Command palette | `Command` inside `Dialog` |
|
||||||
|
| Charts | `Chart` (wraps Recharts) |
|
||||||
|
| Layout | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible` |
|
||||||
|
| Empty states | `Empty` |
|
||||||
|
| Menus | `DropdownMenu`, `ContextMenu`, `Menubar` |
|
||||||
|
| Tooltips/info | `Tooltip`, `HoverCard`, `Popover` |
|
||||||
|
|
||||||
|
## Key Fields
|
||||||
|
|
||||||
|
The injected project context contains these key fields:
|
||||||
|
|
||||||
|
- **`aliases`** → use the actual alias prefix for imports (e.g. `@/`, `~/`), never hardcode.
|
||||||
|
- **`isRSC`** → when `true`, components using `useState`, `useEffect`, event handlers, or browser APIs need `"use client"` at the top of the file. Always reference this field when advising on the directive.
|
||||||
|
- **`tailwindVersion`** → `"v4"` uses `@theme inline` blocks; `"v3"` uses `tailwind.config.js`.
|
||||||
|
- **`tailwindCssFile`** → the global CSS file where custom CSS variables are defined. Always edit this file, never create a new one.
|
||||||
|
- **`style`** → component visual treatment (e.g. `nova`, `vega`).
|
||||||
|
- **`base`** → primitive library (`radix` or `base`). Affects component APIs and available props.
|
||||||
|
- **`iconLibrary`** → determines icon imports. Use `lucide-react` for `lucide`, `@tabler/icons-react` for `tabler`, etc. Never assume `lucide-react`.
|
||||||
|
- **`resolvedPaths`** → exact file-system destinations for components, utils, hooks, etc.
|
||||||
|
- **`framework`** → routing and file conventions (e.g. Next.js App Router vs Vite SPA).
|
||||||
|
- **`packageManager`** → use this for any non-shadcn dependency installs (e.g. `pnpm add date-fns` vs `npm install date-fns`).
|
||||||
|
|
||||||
|
See [cli.md — `info` command](./cli.md) for the full field reference.
|
||||||
|
|
||||||
|
## Component Docs, Examples, and Usage
|
||||||
|
|
||||||
|
Run `npx shadcn@latest docs <component>` to get the URLs for a component's documentation, examples, and API reference. Fetch these URLs to get the actual content.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest docs button dialog select
|
||||||
|
```
|
||||||
|
|
||||||
|
**When creating, fixing, debugging, or using a component, always run `npx shadcn@latest docs` and fetch the URLs first.** This ensures you're working with the correct API and usage patterns rather than guessing.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Get project context** — already injected above. Run `npx shadcn@latest info` again if you need to refresh.
|
||||||
|
2. **Check installed components first** — before running `add`, always check the `components` list from project context or list the `resolvedPaths.ui` directory. Don't import components that haven't been added, and don't re-add ones already installed.
|
||||||
|
3. **Find components** — `npx shadcn@latest search`.
|
||||||
|
4. **Get docs and examples** — run `npx shadcn@latest docs <component>` to get URLs, then fetch them. Use `npx shadcn@latest view` to browse registry items you haven't installed. To preview changes to installed components, use `npx shadcn@latest add --diff`.
|
||||||
|
5. **Install or update** — `npx shadcn@latest add`. When updating existing components, use `--dry-run` and `--diff` to preview changes first (see [Updating Components](#updating-components) below).
|
||||||
|
6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project.
|
||||||
|
7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on.
|
||||||
|
8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
|
||||||
|
9. **Switching presets** — Ask the user first: **reinstall**, **merge**, or **skip**?
|
||||||
|
- **Reinstall**: `npx shadcn@latest init --preset <code> --force --reinstall`. Overwrites all components.
|
||||||
|
- **Merge**: `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually.
|
||||||
|
- **Skip**: `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS, leaves components as-is.
|
||||||
|
- **Important**: Always run preset commands inside the user's project directory. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
|
||||||
|
|
||||||
|
## Updating Components
|
||||||
|
|
||||||
|
When the user asks to update a component from upstream while keeping their local changes, use `--dry-run` and `--diff` to intelligently merge. **NEVER fetch raw files from GitHub manually — always use the CLI.**
|
||||||
|
|
||||||
|
1. Run `npx shadcn@latest add <component> --dry-run` to see all files that would be affected.
|
||||||
|
2. For each file, run `npx shadcn@latest add <component> --diff <file>` to see what changed upstream vs local.
|
||||||
|
3. Decide per file based on the diff:
|
||||||
|
- No local changes → safe to overwrite.
|
||||||
|
- Has local changes → read the local file, analyze the diff, and apply upstream updates while preserving local modifications.
|
||||||
|
- User says "just update everything" → use `--overwrite`, but confirm first.
|
||||||
|
4. **Never use `--overwrite` without the user's explicit approval.**
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a new project.
|
||||||
|
npx shadcn@latest init --name my-app --preset base-nova
|
||||||
|
npx shadcn@latest init --name my-app --preset a2r6bw --template vite
|
||||||
|
|
||||||
|
# Create a monorepo project.
|
||||||
|
npx shadcn@latest init --name my-app --preset base-nova --monorepo
|
||||||
|
npx shadcn@latest init --name my-app --preset base-nova --template next --monorepo
|
||||||
|
|
||||||
|
# Initialize existing project.
|
||||||
|
npx shadcn@latest init --preset base-nova
|
||||||
|
npx shadcn@latest init --defaults # shortcut: --template=next --preset=base-nova
|
||||||
|
|
||||||
|
# Add components.
|
||||||
|
npx shadcn@latest add button card dialog
|
||||||
|
npx shadcn@latest add @magicui/shimmer-button
|
||||||
|
npx shadcn@latest add --all
|
||||||
|
|
||||||
|
# Preview changes before adding/updating.
|
||||||
|
npx shadcn@latest add button --dry-run
|
||||||
|
npx shadcn@latest add button --diff button.tsx
|
||||||
|
npx shadcn@latest add @acme/form --view button.tsx
|
||||||
|
|
||||||
|
# Search registries.
|
||||||
|
npx shadcn@latest search @shadcn -q "sidebar"
|
||||||
|
npx shadcn@latest search @tailark -q "stats"
|
||||||
|
|
||||||
|
# Get component docs and example URLs.
|
||||||
|
npx shadcn@latest docs button dialog select
|
||||||
|
|
||||||
|
# View registry item details (for items not yet installed).
|
||||||
|
npx shadcn@latest view @shadcn/button
|
||||||
|
```
|
||||||
|
|
||||||
|
**Named presets:** `base-nova`, `radix-nova`
|
||||||
|
**Templates:** `next`, `vite`, `start`, `react-router`, `astro` (all support `--monorepo`) and `laravel` (not supported for monorepo)
|
||||||
|
**Preset codes:** Base62 strings starting with `a` (e.g. `a2r6bw`), from [ui.shadcn.com](https://ui.shadcn.com).
|
||||||
|
|
||||||
|
## Detailed References
|
||||||
|
|
||||||
|
- [rules/forms.md](./rules/forms.md) — FieldGroup, Field, InputGroup, ToggleGroup, FieldSet, validation states
|
||||||
|
- [rules/composition.md](./rules/composition.md) — Groups, overlays, Card, Tabs, Avatar, Alert, Empty, Toast, Separator, Skeleton, Badge, Button loading
|
||||||
|
- [rules/icons.md](./rules/icons.md) — data-icon, icon sizing, passing icons as objects
|
||||||
|
- [rules/styling.md](./rules/styling.md) — Semantic colors, variants, className, spacing, size, truncate, dark mode, cn(), z-index
|
||||||
|
- [rules/base-vs-radix.md](./rules/base-vs-radix.md) — asChild vs render, Select, ToggleGroup, Slider, Accordion
|
||||||
|
- [cli.md](./cli.md) — Commands, flags, presets, templates
|
||||||
|
- [customization.md](./customization.md) — Theming, CSS variables, extending components
|
||||||
5
.agents/skills/shadcn/agents/openai.yml
Normal file
5
.agents/skills/shadcn/agents/openai.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "shadcn/ui"
|
||||||
|
short_description: "Manages shadcn/ui components — adding, searching, fixing, debugging, styling, and composing UI."
|
||||||
|
icon_small: "./assets/shadcn-small.png"
|
||||||
|
icon_large: "./assets/shadcn.png"
|
||||||
BIN
.agents/skills/shadcn/assets/shadcn-small.png
Normal file
BIN
.agents/skills/shadcn/assets/shadcn-small.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
BIN
.agents/skills/shadcn/assets/shadcn.png
Normal file
BIN
.agents/skills/shadcn/assets/shadcn.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
257
.agents/skills/shadcn/cli.md
Normal file
257
.agents/skills/shadcn/cli.md
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
# shadcn CLI Reference
|
||||||
|
|
||||||
|
Configuration is read from `components.json`.
|
||||||
|
|
||||||
|
> **IMPORTANT:** Always run commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest`. Check `packageManager` from project context to choose the right one. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
|
||||||
|
|
||||||
|
> **IMPORTANT:** Only use the flags documented below. Do not invent or guess flags — if a flag isn't listed here, it doesn't exist. The CLI auto-detects the package manager from the project's lockfile; there is no `--package-manager` flag.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Commands: init, add (dry-run, smart merge), search, view, docs, info, build
|
||||||
|
- Templates: next, vite, start, react-router, astro
|
||||||
|
- Presets: named, code, URL formats and fields
|
||||||
|
- Switching presets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `init` — Initialize or create a project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest init [components...] [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Initializes shadcn/ui in an existing project or creates a new project (when `--name` is provided). Optionally installs components in the same step.
|
||||||
|
|
||||||
|
| Flag | Short | Description | Default |
|
||||||
|
| ----------------------- | ----- | --------------------------------------------------------- | ------- |
|
||||||
|
| `--template <template>` | `-t` | Template (next, start, vite, next-monorepo, react-router) | — |
|
||||||
|
| `--preset [name]` | `-p` | Preset configuration (named, code, or URL) | — |
|
||||||
|
| `--yes` | `-y` | Skip confirmation prompt | `true` |
|
||||||
|
| `--defaults` | `-d` | Use defaults (`--template=next --preset=base-nova`) | `false` |
|
||||||
|
| `--force` | `-f` | Force overwrite existing configuration | `false` |
|
||||||
|
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||||
|
| `--name <name>` | `-n` | Name for new project | — |
|
||||||
|
| `--silent` | `-s` | Mute output | `false` |
|
||||||
|
| `--rtl` | | Enable RTL support | — |
|
||||||
|
| `--reinstall` | | Re-install existing UI components | `false` |
|
||||||
|
| `--monorepo` | | Scaffold a monorepo project | — |
|
||||||
|
| `--no-monorepo` | | Skip the monorepo prompt | — |
|
||||||
|
|
||||||
|
`npx shadcn@latest create` is an alias for `npx shadcn@latest init`.
|
||||||
|
|
||||||
|
### `add` — Add components
|
||||||
|
|
||||||
|
> **IMPORTANT:** To compare local components against upstream or to preview changes, ALWAYS use `npx shadcn@latest add <component> --dry-run`, `--diff`, or `--view`. NEVER fetch raw files from GitHub or other sources manually. The CLI handles registry resolution, file paths, and CSS diffing automatically.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add [components...] [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepts component names, registry-prefixed names (`@magicui/shimmer-button`), URLs, or local paths.
|
||||||
|
|
||||||
|
| Flag | Short | Description | Default |
|
||||||
|
| --------------- | ----- | -------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||||
|
| `--yes` | `-y` | Skip confirmation prompt | `false` |
|
||||||
|
| `--overwrite` | `-o` | Overwrite existing files | `false` |
|
||||||
|
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||||
|
| `--all` | `-a` | Add all available components | `false` |
|
||||||
|
| `--path <path>` | `-p` | Target path for the component | — |
|
||||||
|
| `--silent` | `-s` | Mute output | `false` |
|
||||||
|
| `--dry-run` | | Preview all changes without writing files | `false` |
|
||||||
|
| `--diff [path]` | | Show diffs. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
|
||||||
|
| `--view [path]` | | Show file contents. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
|
||||||
|
|
||||||
|
#### Dry-Run Mode
|
||||||
|
|
||||||
|
Use `--dry-run` to preview what `add` would do without writing any files. `--diff` and `--view` both imply `--dry-run`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Preview all changes.
|
||||||
|
npx shadcn@latest add button --dry-run
|
||||||
|
|
||||||
|
# Show diffs for all files (top 5).
|
||||||
|
npx shadcn@latest add button --diff
|
||||||
|
|
||||||
|
# Show the diff for a specific file.
|
||||||
|
npx shadcn@latest add button --diff button.tsx
|
||||||
|
|
||||||
|
# Show contents for all files (top 5).
|
||||||
|
npx shadcn@latest add button --view
|
||||||
|
|
||||||
|
# Show the full content of a specific file.
|
||||||
|
npx shadcn@latest add button --view button.tsx
|
||||||
|
|
||||||
|
# Works with URLs too.
|
||||||
|
npx shadcn@latest add https://api.npoint.io/abc123 --dry-run
|
||||||
|
|
||||||
|
# CSS diffs.
|
||||||
|
npx shadcn@latest add button --diff globals.css
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use dry-run:**
|
||||||
|
|
||||||
|
- When the user asks "what files will this add?" or "what will this change?" — use `--dry-run`.
|
||||||
|
- Before overwriting existing components — use `--diff` to preview the changes first.
|
||||||
|
- When the user wants to inspect component source code without installing — use `--view`.
|
||||||
|
- When checking what CSS changes would be made to `globals.css` — use `--diff globals.css`.
|
||||||
|
- When the user asks to review or audit third-party registry code before installing — use `--view` to inspect the source.
|
||||||
|
|
||||||
|
> **`npx shadcn@latest add --dry-run` vs `npx shadcn@latest view`:** Prefer `npx shadcn@latest add --dry-run/--diff/--view` over `npx shadcn@latest view` when the user wants to preview changes to their project. `npx shadcn@latest view` only shows raw registry metadata. `npx shadcn@latest add --dry-run` shows exactly what would happen in the user's project: resolved file paths, diffs against existing files, and CSS updates. Use `npx shadcn@latest view` only when the user wants to browse registry info without a project context.
|
||||||
|
|
||||||
|
#### Smart Merge from Upstream
|
||||||
|
|
||||||
|
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full workflow.
|
||||||
|
|
||||||
|
### `search` — Search registries
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest search <registries...> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Fuzzy search across registries. Also aliased as `npx shadcn@latest list`. Without `-q`, lists all items.
|
||||||
|
|
||||||
|
| Flag | Short | Description | Default |
|
||||||
|
| ------------------- | ----- | ---------------------- | ------- |
|
||||||
|
| `--query <query>` | `-q` | Search query | — |
|
||||||
|
| `--limit <number>` | `-l` | Max items per registry | `100` |
|
||||||
|
| `--offset <number>` | `-o` | Items to skip | `0` |
|
||||||
|
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||||
|
|
||||||
|
### `view` — View item details
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest view <items...> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Displays item info including file contents. Example: `npx shadcn@latest view @shadcn/button`.
|
||||||
|
|
||||||
|
### `docs` — Get component documentation URLs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest docs <components...> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs resolved URLs for component documentation, examples, and API references. Accepts one or more component names. Fetch the URLs to get the actual content.
|
||||||
|
|
||||||
|
Example output for `npx shadcn@latest docs input button`:
|
||||||
|
|
||||||
|
```
|
||||||
|
base radix
|
||||||
|
|
||||||
|
input
|
||||||
|
docs https://ui.shadcn.com/docs/components/radix/input
|
||||||
|
examples https://raw.githubusercontent.com/.../examples/input-example.tsx
|
||||||
|
|
||||||
|
button
|
||||||
|
docs https://ui.shadcn.com/docs/components/radix/button
|
||||||
|
examples https://raw.githubusercontent.com/.../examples/button-example.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Some components include an `api` link to the underlying library (e.g. `cmdk` for the command component).
|
||||||
|
|
||||||
|
### `diff` — Check for updates
|
||||||
|
|
||||||
|
Do not use this command. Use `npx shadcn@latest add --diff` instead.
|
||||||
|
|
||||||
|
### `info` — Project information
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest info [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Displays project info and `components.json` configuration. Run this first to discover the project's framework, aliases, Tailwind version, and resolved paths.
|
||||||
|
|
||||||
|
| Flag | Short | Description | Default |
|
||||||
|
| ------------- | ----- | ----------------- | ------- |
|
||||||
|
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||||
|
|
||||||
|
**Project Info fields:**
|
||||||
|
|
||||||
|
| Field | Type | Meaning |
|
||||||
|
| -------------------- | --------- | ------------------------------------------------------------------ |
|
||||||
|
| `framework` | `string` | Detected framework (`next`, `vite`, `react-router`, `start`, etc.) |
|
||||||
|
| `frameworkVersion` | `string` | Framework version (e.g. `15.2.4`) |
|
||||||
|
| `isSrcDir` | `boolean` | Whether the project uses a `src/` directory |
|
||||||
|
| `isRSC` | `boolean` | Whether React Server Components are enabled |
|
||||||
|
| `isTsx` | `boolean` | Whether the project uses TypeScript |
|
||||||
|
| `tailwindVersion` | `string` | `"v3"` or `"v4"` |
|
||||||
|
| `tailwindConfigFile` | `string` | Path to the Tailwind config file |
|
||||||
|
| `tailwindCssFile` | `string` | Path to the global CSS file |
|
||||||
|
| `aliasPrefix` | `string` | Import alias prefix (e.g. `@`, `~`, `@/`) |
|
||||||
|
| `packageManager` | `string` | Detected package manager (`npm`, `pnpm`, `yarn`, `bun`) |
|
||||||
|
|
||||||
|
**Components.json fields:**
|
||||||
|
|
||||||
|
| Field | Type | Meaning |
|
||||||
|
| -------------------- | --------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| `base` | `string` | Primitive library (`radix` or `base`) — determines component APIs and available props |
|
||||||
|
| `style` | `string` | Visual style (e.g. `nova`, `vega`) |
|
||||||
|
| `rsc` | `boolean` | RSC flag from config |
|
||||||
|
| `tsx` | `boolean` | TypeScript flag |
|
||||||
|
| `tailwind.config` | `string` | Tailwind config path |
|
||||||
|
| `tailwind.css` | `string` | Global CSS path — this is where custom CSS variables go |
|
||||||
|
| `iconLibrary` | `string` | Icon library — determines icon import package (e.g. `lucide-react`, `@tabler/icons-react`) |
|
||||||
|
| `aliases.components` | `string` | Component import alias (e.g. `@/components`) |
|
||||||
|
| `aliases.utils` | `string` | Utils import alias (e.g. `@/lib/utils`) |
|
||||||
|
| `aliases.ui` | `string` | UI component alias (e.g. `@/components/ui`) |
|
||||||
|
| `aliases.lib` | `string` | Lib alias (e.g. `@/lib`) |
|
||||||
|
| `aliases.hooks` | `string` | Hooks alias (e.g. `@/hooks`) |
|
||||||
|
| `resolvedPaths` | `object` | Absolute file-system paths for each alias |
|
||||||
|
| `registries` | `object` | Configured custom registries |
|
||||||
|
|
||||||
|
**Links fields:**
|
||||||
|
|
||||||
|
The `info` output includes a **Links** section with templated URLs for component docs, source, and examples. For resolved URLs, use `npx shadcn@latest docs <component>` instead.
|
||||||
|
|
||||||
|
### `build` — Build a custom registry
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest build [registry] [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Builds `registry.json` into individual JSON files for distribution. Default input: `./registry.json`, default output: `./public/r`.
|
||||||
|
|
||||||
|
| Flag | Short | Description | Default |
|
||||||
|
| ----------------- | ----- | ----------------- | ------------ |
|
||||||
|
| `--output <path>` | `-o` | Output directory | `./public/r` |
|
||||||
|
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Templates
|
||||||
|
|
||||||
|
| Value | Framework | Monorepo support |
|
||||||
|
| -------------- | -------------- | ---------------- |
|
||||||
|
| `next` | Next.js | Yes |
|
||||||
|
| `vite` | Vite | Yes |
|
||||||
|
| `start` | TanStack Start | Yes |
|
||||||
|
| `react-router` | React Router | Yes |
|
||||||
|
| `astro` | Astro | Yes |
|
||||||
|
| `laravel` | Laravel | No |
|
||||||
|
|
||||||
|
All templates support monorepo scaffolding via the `--monorepo` flag. When passed, the CLI uses a monorepo-specific template directory (e.g. `next-monorepo`, `vite-monorepo`). When neither `--monorepo` nor `--no-monorepo` is passed, the CLI prompts interactively. Laravel does not support monorepo scaffolding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Presets
|
||||||
|
|
||||||
|
Three ways to specify a preset via `--preset`:
|
||||||
|
|
||||||
|
1. **Named:** `--preset base-nova` or `--preset radix-nova`
|
||||||
|
2. **Code:** `--preset a2r6bw` (base62 string, starts with lowercase `a`)
|
||||||
|
3. **URL:** `--preset "https://ui.shadcn.com/init?base=radix&style=nova&..."`
|
||||||
|
|
||||||
|
> **IMPORTANT:** Never try to decode, fetch, or resolve preset codes manually. Preset codes are opaque — pass them directly to `npx shadcn@latest init --preset <code>` and let the CLI handle resolution.
|
||||||
|
|
||||||
|
## Switching Presets
|
||||||
|
|
||||||
|
Ask the user first: **reinstall**, **merge**, or **skip** existing components?
|
||||||
|
|
||||||
|
- **Re-install** → `npx shadcn@latest init --preset <code> --force --reinstall`. Overwrites all component files with the new preset styles. Use when the user hasn't customized components.
|
||||||
|
- **Merge** → `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to get the list of installed components and use the [smart merge workflow](./SKILL.md#updating-components) to update them one by one, preserving local changes. Use when the user has customized components.
|
||||||
|
- **Skip** → `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS variables, leaves existing components as-is.
|
||||||
|
|
||||||
|
Always run preset commands inside the user's project directory. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
|
||||||
203
.agents/skills/shadcn/customization.md
Normal file
203
.agents/skills/shadcn/customization.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# Customization & Theming
|
||||||
|
|
||||||
|
Components reference semantic CSS variable tokens. Change the variables to change every component.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- How it works (CSS variables → Tailwind utilities → components)
|
||||||
|
- Color variables and OKLCH format
|
||||||
|
- Dark mode setup
|
||||||
|
- Changing the theme (presets, CSS variables)
|
||||||
|
- Adding custom colors (Tailwind v3 and v4)
|
||||||
|
- Border radius
|
||||||
|
- Customizing components (variants, className, wrappers)
|
||||||
|
- Checking for updates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. CSS variables defined in `:root` (light) and `.dark` (dark mode).
|
||||||
|
2. Tailwind maps them to utilities: `bg-primary`, `text-muted-foreground`, etc.
|
||||||
|
3. Components use these utilities — changing a variable changes all components that reference it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Color Variables
|
||||||
|
|
||||||
|
Every color follows the `name` / `name-foreground` convention. The base variable is for backgrounds, `-foreground` is for text/icons on that background.
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
| -------------------------------------------- | -------------------------------- |
|
||||||
|
| `--background` / `--foreground` | Page background and default text |
|
||||||
|
| `--card` / `--card-foreground` | Card surfaces |
|
||||||
|
| `--primary` / `--primary-foreground` | Primary buttons and actions |
|
||||||
|
| `--secondary` / `--secondary-foreground` | Secondary actions |
|
||||||
|
| `--muted` / `--muted-foreground` | Muted/disabled states |
|
||||||
|
| `--accent` / `--accent-foreground` | Hover and accent states |
|
||||||
|
| `--destructive` / `--destructive-foreground` | Error and destructive actions |
|
||||||
|
| `--border` | Default border color |
|
||||||
|
| `--input` | Form input borders |
|
||||||
|
| `--ring` | Focus ring color |
|
||||||
|
| `--chart-1` through `--chart-5` | Chart/data visualization |
|
||||||
|
| `--sidebar-*` | Sidebar-specific colors |
|
||||||
|
| `--surface` / `--surface-foreground` | Secondary surface |
|
||||||
|
|
||||||
|
Colors use OKLCH: `--primary: oklch(0.205 0 0)` where values are lightness (0–1), chroma (0 = gray), and hue (0–360).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dark Mode
|
||||||
|
|
||||||
|
Class-based toggle via `.dark` on the root element. In Next.js, use `next-themes`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ThemeProvider } from "next-themes";
|
||||||
|
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changing the Theme
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Apply a preset code from ui.shadcn.com.
|
||||||
|
npx shadcn@latest init --preset a2r6bw --force
|
||||||
|
|
||||||
|
# Switch to a named preset.
|
||||||
|
npx shadcn@latest init --preset radix-nova --force
|
||||||
|
npx shadcn@latest init --reinstall # update existing components to match
|
||||||
|
|
||||||
|
# Use a custom theme URL.
|
||||||
|
npx shadcn@latest init --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..." --force
|
||||||
|
```
|
||||||
|
|
||||||
|
Or edit CSS variables directly in `globals.css`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding Custom Colors
|
||||||
|
|
||||||
|
Add variables to the file at `tailwindCssFile` from `npx shadcn@latest info` (typically `globals.css`). Never create a new CSS file for this.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 1. Define in the global CSS file. */
|
||||||
|
:root {
|
||||||
|
--warning: oklch(0.84 0.16 84);
|
||||||
|
--warning-foreground: oklch(0.28 0.07 46);
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
--warning: oklch(0.41 0.11 46);
|
||||||
|
--warning-foreground: oklch(0.99 0.02 95);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 2a. Register with Tailwind v4 (@theme inline). */
|
||||||
|
@theme inline {
|
||||||
|
--color-warning: var(--warning);
|
||||||
|
--color-warning-foreground: var(--warning-foreground);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `tailwindVersion` is `"v3"` (check via `npx shadcn@latest info`), register in `tailwind.config.js` instead:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 2b. Register with Tailwind v3 (tailwind.config.js).
|
||||||
|
module.exports = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
warning: "oklch(var(--warning) / <alpha-value>)",
|
||||||
|
"warning-foreground": "oklch(var(--warning-foreground) / <alpha-value>)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 3. Use in components.
|
||||||
|
<div className="bg-warning text-warning-foreground">Warning</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Border Radius
|
||||||
|
|
||||||
|
`--radius` controls border radius globally. Components derive values from it (`rounded-lg` = `var(--radius)`, `rounded-md` = `calc(var(--radius) - 2px)`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Customizing Components
|
||||||
|
|
||||||
|
See also: [rules/styling.md](./rules/styling.md) for Incorrect/Correct examples.
|
||||||
|
|
||||||
|
Prefer these approaches in order:
|
||||||
|
|
||||||
|
### 1. Built-in variants
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Click
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Tailwind classes via `className`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card className="max-w-md mx-auto">...</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Add a new variant
|
||||||
|
|
||||||
|
Edit the component source to add a variant via `cva`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/ui/button.tsx
|
||||||
|
warning: "bg-warning text-warning-foreground hover:bg-warning/90",
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Wrapper components
|
||||||
|
|
||||||
|
Compose shadcn/ui primitives into higher-level components:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export function ConfirmDialog({ title, description, onConfirm, children }) {
|
||||||
|
return (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={onConfirm}>Confirm</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checking for Updates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add button --diff
|
||||||
|
```
|
||||||
|
|
||||||
|
To preview exactly what would change before updating, use `--dry-run` and `--diff`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add button --dry-run # see all affected files
|
||||||
|
npx shadcn@latest add button --diff button.tsx # see the diff for a specific file
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full smart merge workflow.
|
||||||
47
.agents/skills/shadcn/evals/evals.json
Normal file
47
.agents/skills/shadcn/evals/evals.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"skill_name": "shadcn",
|
||||||
|
"evals": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"prompt": "I'm building a Next.js app with shadcn/ui (base-nova preset, lucide icons). Create a settings form component with fields for: full name, email address, and notification preferences (email, SMS, push notifications as toggle options). Add validation states for required fields.",
|
||||||
|
"expected_output": "A React component using FieldGroup, Field, ToggleGroup, data-invalid/aria-invalid validation, gap-* spacing, and semantic colors.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Uses FieldGroup and Field components for form layout instead of raw div with space-y",
|
||||||
|
"Uses Switch for independent on/off notification toggles (not looping Button with manual active state)",
|
||||||
|
"Uses data-invalid on Field and aria-invalid on the input control for validation states",
|
||||||
|
"Uses gap-* (e.g. gap-4, gap-6) instead of space-y-* or space-x-* for spacing",
|
||||||
|
"Uses semantic color tokens (e.g. bg-background, text-muted-foreground, text-destructive) instead of raw colors like bg-red-500",
|
||||||
|
"No manual dark: color overrides"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"prompt": "Create a dialog component for editing a user profile. It should have the user's avatar at the top, input fields for name and bio, and Save/Cancel buttons with appropriate icons. Using shadcn/ui with radix-nova preset and tabler icons.",
|
||||||
|
"expected_output": "A React component with DialogTitle, Avatar+AvatarFallback, data-icon on icon buttons, no icon sizing classes, tabler icon imports.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Includes DialogTitle for accessibility (visible or with sr-only class)",
|
||||||
|
"Avatar component includes AvatarFallback",
|
||||||
|
"Icons on buttons use the data-icon attribute (data-icon=\"inline-start\" or data-icon=\"inline-end\")",
|
||||||
|
"No sizing classes on icons inside components (no size-4, w-4, h-4, etc.)",
|
||||||
|
"Uses tabler icons (@tabler/icons-react) instead of lucide-react",
|
||||||
|
"Uses asChild for custom triggers (radix preset)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"prompt": "Create a dashboard component that shows 4 stat cards in a grid. Each card has a title, large number, percentage change badge, and a loading skeleton state. Using shadcn/ui with base-nova preset and lucide icons.",
|
||||||
|
"expected_output": "A React component with full Card composition, Skeleton for loading, Badge for changes, semantic colors, gap-* spacing.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Uses full Card composition with CardHeader, CardTitle, CardContent (not dumping everything into CardContent)",
|
||||||
|
"Uses Skeleton component for loading placeholders instead of custom animate-pulse divs",
|
||||||
|
"Uses Badge component for percentage change instead of custom styled spans",
|
||||||
|
"Uses semantic color tokens instead of raw color values like bg-green-500 or text-red-600",
|
||||||
|
"Uses gap-* instead of space-y-* or space-x-* for spacing",
|
||||||
|
"Uses size-* when width and height are equal instead of separate w-* h-*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
94
.agents/skills/shadcn/mcp.md
Normal file
94
.agents/skills/shadcn/mcp.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# shadcn MCP Server
|
||||||
|
|
||||||
|
The CLI includes an MCP server that lets AI assistants search, browse, view, and install components from registries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
shadcn mcp # start the MCP server (stdio)
|
||||||
|
shadcn mcp init # write config for your editor
|
||||||
|
```
|
||||||
|
|
||||||
|
Editor config files:
|
||||||
|
|
||||||
|
| Editor | Config file |
|
||||||
|
| ----------- | ------------------------------- |
|
||||||
|
| Claude Code | `.mcp.json` |
|
||||||
|
| Cursor | `.cursor/mcp.json` |
|
||||||
|
| VS Code | `.vscode/mcp.json` |
|
||||||
|
| OpenCode | `opencode.json` |
|
||||||
|
| Codex | `~/.codex/config.toml` (manual) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
> **Tip:** MCP tools handle registry operations (search, view, install). For project configuration (aliases, framework, Tailwind version), use `npx shadcn@latest info` — there is no MCP equivalent.
|
||||||
|
|
||||||
|
### `shadcn:get_project_registries`
|
||||||
|
|
||||||
|
Returns registry names from `components.json`. Errors if no `components.json` exists.
|
||||||
|
|
||||||
|
**Input:** none
|
||||||
|
|
||||||
|
### `shadcn:list_items_in_registries`
|
||||||
|
|
||||||
|
Lists all items from one or more registries.
|
||||||
|
|
||||||
|
**Input:** `registries` (string[]), `limit` (number, optional), `offset` (number, optional)
|
||||||
|
|
||||||
|
### `shadcn:search_items_in_registries`
|
||||||
|
|
||||||
|
Fuzzy search across registries.
|
||||||
|
|
||||||
|
**Input:** `registries` (string[]), `query` (string), `limit` (number, optional), `offset` (number, optional)
|
||||||
|
|
||||||
|
### `shadcn:view_items_in_registries`
|
||||||
|
|
||||||
|
View item details including full file contents.
|
||||||
|
|
||||||
|
**Input:** `items` (string[]) — e.g. `["@shadcn/button", "@shadcn/card"]`
|
||||||
|
|
||||||
|
### `shadcn:get_item_examples_from_registries`
|
||||||
|
|
||||||
|
Find usage examples and demos with source code.
|
||||||
|
|
||||||
|
**Input:** `registries` (string[]), `query` (string) — e.g. `"accordion-demo"`, `"button example"`
|
||||||
|
|
||||||
|
### `shadcn:get_add_command_for_items`
|
||||||
|
|
||||||
|
Returns the CLI install command.
|
||||||
|
|
||||||
|
**Input:** `items` (string[]) — e.g. `["@shadcn/button"]`
|
||||||
|
|
||||||
|
### `shadcn:get_audit_checklist`
|
||||||
|
|
||||||
|
Returns a checklist for verifying components (imports, deps, lint, TypeScript).
|
||||||
|
|
||||||
|
**Input:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuring Registries
|
||||||
|
|
||||||
|
Registries are set in `components.json`. The `@shadcn` registry is always built-in.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registries": {
|
||||||
|
"@acme": "https://acme.com/r/{name}.json",
|
||||||
|
"@private": {
|
||||||
|
"url": "https://private.com/r/{name}.json",
|
||||||
|
"headers": { "Authorization": "Bearer ${MY_TOKEN}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Names must start with `@`.
|
||||||
|
- URLs must contain `{name}`.
|
||||||
|
- `${VAR}` references are resolved from environment variables.
|
||||||
|
|
||||||
|
Community registry index: `https://ui.shadcn.com/r/registries.json`
|
||||||
308
.agents/skills/shadcn/rules/base-vs-radix.md
Normal file
308
.agents/skills/shadcn/rules/base-vs-radix.md
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
# Base vs Radix
|
||||||
|
|
||||||
|
API differences between `base` and `radix`. Check the `base` field from `npx shadcn@latest info`.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Composition: asChild vs render
|
||||||
|
- Button / trigger as non-button element
|
||||||
|
- Select (items prop, placeholder, positioning, multiple, object values)
|
||||||
|
- ToggleGroup (type vs multiple)
|
||||||
|
- Slider (scalar vs array)
|
||||||
|
- Accordion (type and defaultValue)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Composition: asChild (radix) vs render (base)
|
||||||
|
|
||||||
|
Radix uses `asChild` to replace the default element. Base uses `render`. Don't wrap triggers in extra elements.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DialogTrigger>
|
||||||
|
<div>
|
||||||
|
<Button>Open</Button>
|
||||||
|
</div>
|
||||||
|
</DialogTrigger>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (radix):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>Open</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DialogTrigger render={<Button />}>Open</DialogTrigger>
|
||||||
|
```
|
||||||
|
|
||||||
|
This applies to all trigger and close components: `DialogTrigger`, `SheetTrigger`, `AlertDialogTrigger`, `DropdownMenuTrigger`, `PopoverTrigger`, `TooltipTrigger`, `CollapsibleTrigger`, `DialogClose`, `SheetClose`, `NavigationMenuLink`, `BreadcrumbLink`, `SidebarMenuButton`, `Badge`, `Item`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Button / trigger as non-button element (base only)
|
||||||
|
|
||||||
|
When `render` changes an element to a non-button (`<a>`, `<span>`), add `nativeButton={false}`.
|
||||||
|
|
||||||
|
**Incorrect (base):** missing `nativeButton={false}`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button render={<a href="/docs" />}>Read the docs</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button render={<a href="/docs" />} nativeButton={false}>
|
||||||
|
Read the docs
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (radix):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button asChild>
|
||||||
|
<a href="/docs">Read the docs</a>
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Same for triggers whose `render` is not a `Button`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// base.
|
||||||
|
<PopoverTrigger render={<InputGroupAddon />} nativeButton={false}>
|
||||||
|
Pick date
|
||||||
|
</PopoverTrigger>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Select
|
||||||
|
|
||||||
|
**items prop (base only).** Base requires an `items` prop on the root. Radix uses inline JSX only.
|
||||||
|
|
||||||
|
**Incorrect (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a fruit" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const items = [
|
||||||
|
{ label: "Select a fruit", value: null },
|
||||||
|
{ label: "Apple", value: "apple" },
|
||||||
|
{ label: "Banana", value: "banana" },
|
||||||
|
]
|
||||||
|
|
||||||
|
<Select items={items}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{items.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (radix):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a fruit" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="apple">Apple</SelectItem>
|
||||||
|
<SelectItem value="banana">Banana</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Placeholder.** Base uses a `{ value: null }` item in the items array. Radix uses `<SelectValue placeholder="...">`.
|
||||||
|
|
||||||
|
**Content positioning.** Base uses `alignItemWithTrigger`. Radix uses `position`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// base.
|
||||||
|
<SelectContent alignItemWithTrigger={false} side="bottom">
|
||||||
|
|
||||||
|
// radix.
|
||||||
|
<SelectContent position="popper">
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Select — multiple selection and object values (base only)
|
||||||
|
|
||||||
|
Base supports `multiple`, render-function children on `SelectValue`, and object values with `itemToStringValue`. Radix is single-select with string values only.
|
||||||
|
|
||||||
|
**Correct (base — multiple selection):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Select items={items} multiple defaultValue={[]}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue>
|
||||||
|
{(value: string[]) => (value.length === 0 ? "Select fruits" : `${value.length} selected`)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
...
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base — object values):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Select defaultValue={plans[0]} itemToStringValue={(plan) => plan.name}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue>{(value) => value.name}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
...
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ToggleGroup
|
||||||
|
|
||||||
|
Base uses a `multiple` boolean prop. Radix uses `type="single"` or `type="multiple"`.
|
||||||
|
|
||||||
|
**Incorrect (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ToggleGroup type="single" defaultValue="daily">
|
||||||
|
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Single (no prop needed), defaultValue is always an array.
|
||||||
|
<ToggleGroup defaultValue={["daily"]} spacing={2}>
|
||||||
|
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
|
||||||
|
// Multi-selection.
|
||||||
|
<ToggleGroup multiple>
|
||||||
|
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (radix):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Single, defaultValue is a string.
|
||||||
|
<ToggleGroup type="single" defaultValue="daily" spacing={2}>
|
||||||
|
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
|
||||||
|
// Multi-selection.
|
||||||
|
<ToggleGroup type="multiple">
|
||||||
|
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Controlled single value:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// base — wrap/unwrap arrays.
|
||||||
|
const [value, setValue] = React.useState("normal")
|
||||||
|
<ToggleGroup value={[value]} onValueChange={(v) => setValue(v[0])}>
|
||||||
|
|
||||||
|
// radix — plain string.
|
||||||
|
const [value, setValue] = React.useState("normal")
|
||||||
|
<ToggleGroup type="single" value={value} onValueChange={setValue}>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slider
|
||||||
|
|
||||||
|
Base accepts a plain number for a single thumb. Radix always requires an array.
|
||||||
|
|
||||||
|
**Incorrect (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Slider defaultValue={[50]} max={100} step={1} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Slider defaultValue={50} max={100} step={1} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (radix):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Slider defaultValue={[50]} max={100} step={1} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Both use arrays for range sliders. Controlled `onValueChange` in base may need a cast:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// base.
|
||||||
|
const [value, setValue] = React.useState([0.3, 0.7])
|
||||||
|
<Slider value={value} onValueChange={(v) => setValue(v as number[])} />
|
||||||
|
|
||||||
|
// radix.
|
||||||
|
const [value, setValue] = React.useState([0.3, 0.7])
|
||||||
|
<Slider value={value} onValueChange={setValue} />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accordion
|
||||||
|
|
||||||
|
Radix requires `type="single"` or `type="multiple"` and supports `collapsible`. `defaultValue` is a string. Base uses no `type` prop, uses `multiple` boolean, and `defaultValue` is always an array.
|
||||||
|
|
||||||
|
**Incorrect (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Accordion type="single" collapsible defaultValue="item-1">
|
||||||
|
<AccordionItem value="item-1">...</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Accordion defaultValue={["item-1"]}>
|
||||||
|
<AccordionItem value="item-1">...</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
// Multi-select.
|
||||||
|
<Accordion multiple defaultValue={["item-1", "item-2"]}>
|
||||||
|
<AccordionItem value="item-1">...</AccordionItem>
|
||||||
|
<AccordionItem value="item-2">...</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (radix):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Accordion type="single" collapsible defaultValue="item-1">
|
||||||
|
<AccordionItem value="item-1">...</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
```
|
||||||
197
.agents/skills/shadcn/rules/composition.md
Normal file
197
.agents/skills/shadcn/rules/composition.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# Component Composition
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Items always inside their Group component
|
||||||
|
- Callouts use Alert
|
||||||
|
- Empty states use Empty component
|
||||||
|
- Toast notifications use sonner
|
||||||
|
- Choosing between overlay components
|
||||||
|
- Dialog, Sheet, and Drawer always need a Title
|
||||||
|
- Card structure
|
||||||
|
- Button has no isPending or isLoading prop
|
||||||
|
- TabsTrigger must be inside TabsList
|
||||||
|
- Avatar always needs AvatarFallback
|
||||||
|
- Use Separator instead of raw hr or border divs
|
||||||
|
- Use Skeleton for loading placeholders
|
||||||
|
- Use Badge instead of custom styled spans
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Items always inside their Group component
|
||||||
|
|
||||||
|
Never render items directly inside the content container.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="apple">Apple</SelectItem>
|
||||||
|
<SelectItem value="banana">Banana</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="apple">Apple</SelectItem>
|
||||||
|
<SelectItem value="banana">Banana</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
This applies to all group-based components:
|
||||||
|
|
||||||
|
| Item | Group |
|
||||||
|
| ---------------------------------------------------------- | ------------------- |
|
||||||
|
| `SelectItem`, `SelectLabel` | `SelectGroup` |
|
||||||
|
| `DropdownMenuItem`, `DropdownMenuLabel`, `DropdownMenuSub` | `DropdownMenuGroup` |
|
||||||
|
| `MenubarItem` | `MenubarGroup` |
|
||||||
|
| `ContextMenuItem` | `ContextMenuGroup` |
|
||||||
|
| `CommandItem` | `CommandGroup` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Callouts use Alert
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Alert>
|
||||||
|
<AlertTitle>Warning</AlertTitle>
|
||||||
|
<AlertDescription>Something needs attention.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empty states use Empty component
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Empty>
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia variant="icon">
|
||||||
|
<FolderIcon />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>No projects yet</EmptyTitle>
|
||||||
|
<EmptyDescription>Get started by creating a new project.</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
<EmptyContent>
|
||||||
|
<Button>Create Project</Button>
|
||||||
|
</EmptyContent>
|
||||||
|
</Empty>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Toast notifications use sonner
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
toast.success("Changes saved.");
|
||||||
|
toast.error("Something went wrong.");
|
||||||
|
toast("File deleted.", {
|
||||||
|
action: { label: "Undo", onClick: () => undoDelete() },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Choosing between overlay components
|
||||||
|
|
||||||
|
| Use case | Component |
|
||||||
|
| ---------------------------------- | ------------- |
|
||||||
|
| Focused task that requires input | `Dialog` |
|
||||||
|
| Destructive action confirmation | `AlertDialog` |
|
||||||
|
| Side panel with details or filters | `Sheet` |
|
||||||
|
| Mobile-first bottom panel | `Drawer` |
|
||||||
|
| Quick info on hover | `HoverCard` |
|
||||||
|
| Small contextual content on click | `Popover` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dialog, Sheet, and Drawer always need a Title
|
||||||
|
|
||||||
|
`DialogTitle`, `SheetTitle`, `DrawerTitle` are required for accessibility. Use `className="sr-only"` if visually hidden.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Profile</DialogTitle>
|
||||||
|
<DialogDescription>Update your profile.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
...
|
||||||
|
</DialogContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Card structure
|
||||||
|
|
||||||
|
Use full composition — don't dump everything into `CardContent`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Team Members</CardTitle>
|
||||||
|
<CardDescription>Manage your team.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>...</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button>Invite</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Button has no isPending or isLoading prop
|
||||||
|
|
||||||
|
Compose with `Spinner` + `data-icon` + `disabled`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button disabled>
|
||||||
|
<Spinner data-icon="inline-start" />
|
||||||
|
Saving...
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TabsTrigger must be inside TabsList
|
||||||
|
|
||||||
|
Never render `TabsTrigger` directly inside `Tabs` — always wrap in `TabsList`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Tabs defaultValue="account">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="account">Account</TabsTrigger>
|
||||||
|
<TabsTrigger value="password">Password</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="account">...</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Avatar always needs AvatarFallback
|
||||||
|
|
||||||
|
Always include `AvatarFallback` for when the image fails to load:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage src="/avatar.png" alt="User" />
|
||||||
|
<AvatarFallback>JD</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use existing components instead of custom markup
|
||||||
|
|
||||||
|
| Instead of | Use |
|
||||||
|
| -------------------------------------------------- | ------------------------------------ |
|
||||||
|
| `<hr>` or `<div className="border-t">` | `<Separator />` |
|
||||||
|
| `<div className="animate-pulse">` with styled divs | `<Skeleton className="h-4 w-3/4" />` |
|
||||||
|
| `<span className="rounded-full bg-green-100 ...">` | `<Badge variant="secondary">` |
|
||||||
194
.agents/skills/shadcn/rules/forms.md
Normal file
194
.agents/skills/shadcn/rules/forms.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# Forms & Inputs
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Forms use FieldGroup + Field
|
||||||
|
- InputGroup requires InputGroupInput/InputGroupTextarea
|
||||||
|
- Buttons inside inputs use InputGroup + InputGroupAddon
|
||||||
|
- Option sets (2–7 choices) use ToggleGroup
|
||||||
|
- FieldSet + FieldLegend for grouping related fields
|
||||||
|
- Field validation and disabled states
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Forms use FieldGroup + Field
|
||||||
|
|
||||||
|
Always use `FieldGroup` + `Field` — never raw `div` with `space-y-*`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||||
|
<Input id="email" type="email" />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="password">Password</FieldLabel>
|
||||||
|
<Input id="password" type="password" />
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `Field orientation="horizontal"` for settings pages. Use `FieldLabel className="sr-only"` for visually hidden labels.
|
||||||
|
|
||||||
|
**Choosing form controls:**
|
||||||
|
|
||||||
|
- Simple text input → `Input`
|
||||||
|
- Dropdown with predefined options → `Select`
|
||||||
|
- Searchable dropdown → `Combobox`
|
||||||
|
- Native HTML select (no JS) → `native-select`
|
||||||
|
- Boolean toggle → `Switch` (for settings) or `Checkbox` (for forms)
|
||||||
|
- Single choice from few options → `RadioGroup`
|
||||||
|
- Toggle between 2–5 options → `ToggleGroup` + `ToggleGroupItem`
|
||||||
|
- OTP/verification code → `InputOTP`
|
||||||
|
- Multi-line text → `Textarea`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## InputGroup requires InputGroupInput/InputGroupTextarea
|
||||||
|
|
||||||
|
Never use raw `Input` or `Textarea` inside an `InputGroup`.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<InputGroup>
|
||||||
|
<Input placeholder="Search..." />
|
||||||
|
</InputGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { InputGroup, InputGroupInput } from "@/components/ui/input-group";
|
||||||
|
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupInput placeholder="Search..." />
|
||||||
|
</InputGroup>;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Buttons inside inputs use InputGroup + InputGroupAddon
|
||||||
|
|
||||||
|
Never place a `Button` directly inside or adjacent to an `Input` with custom positioning.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="relative">
|
||||||
|
<Input placeholder="Search..." className="pr-10" />
|
||||||
|
<Button className="absolute right-0 top-0" size="icon">
|
||||||
|
<SearchIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { InputGroup, InputGroupInput, InputGroupAddon } from "@/components/ui/input-group";
|
||||||
|
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupInput placeholder="Search..." />
|
||||||
|
<InputGroupAddon>
|
||||||
|
<Button size="icon">
|
||||||
|
<SearchIcon data-icon="inline-start" />
|
||||||
|
</Button>
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option sets (2–7 choices) use ToggleGroup
|
||||||
|
|
||||||
|
Don't manually loop `Button` components with active state.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [selected, setSelected] = useState("daily")
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{["daily", "weekly", "monthly"].map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option}
|
||||||
|
variant={selected === option ? "default" : "outline"}
|
||||||
|
onClick={() => setSelected(option)}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
|
|
||||||
|
<ToggleGroup spacing={2}>
|
||||||
|
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="monthly">Monthly</ToggleGroupItem>
|
||||||
|
</ToggleGroup>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Combine with `Field` for labelled toggle groups:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<FieldTitle id="theme-label">Theme</FieldTitle>
|
||||||
|
<ToggleGroup aria-labelledby="theme-label" spacing={2}>
|
||||||
|
<ToggleGroupItem value="light">Light</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="dark">Dark</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="system">System</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
</Field>
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** `defaultValue` and `type`/`multiple` props differ between base and radix. See [base-vs-radix.md](./base-vs-radix.md#togglegroup).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FieldSet + FieldLegend for grouping related fields
|
||||||
|
|
||||||
|
Use `FieldSet` + `FieldLegend` for related checkboxes, radios, or switches — not `div` with a heading:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<FieldSet>
|
||||||
|
<FieldLegend variant="label">Preferences</FieldLegend>
|
||||||
|
<FieldDescription>Select all that apply.</FieldDescription>
|
||||||
|
<FieldGroup className="gap-3">
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Checkbox id="dark" />
|
||||||
|
<FieldLabel htmlFor="dark" className="font-normal">
|
||||||
|
Dark mode
|
||||||
|
</FieldLabel>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Field validation and disabled states
|
||||||
|
|
||||||
|
Both attributes are needed — `data-invalid`/`data-disabled` styles the field (label, description), while `aria-invalid`/`disabled` styles the control.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Invalid.
|
||||||
|
<Field data-invalid>
|
||||||
|
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||||
|
<Input id="email" aria-invalid />
|
||||||
|
<FieldDescription>Invalid email address.</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
// Disabled.
|
||||||
|
<Field data-disabled>
|
||||||
|
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||||
|
<Input id="email" disabled />
|
||||||
|
</Field>
|
||||||
|
```
|
||||||
|
|
||||||
|
Works for all controls: `Input`, `Textarea`, `Select`, `Checkbox`, `RadioGroupItem`, `Switch`, `Slider`, `NativeSelect`, `InputOTP`.
|
||||||
101
.agents/skills/shadcn/rules/icons.md
Normal file
101
.agents/skills/shadcn/rules/icons.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Icons
|
||||||
|
|
||||||
|
**Always use the project's configured `iconLibrary` for imports.** Check the `iconLibrary` field from project context: `lucide` → `lucide-react`, `tabler` → `@tabler/icons-react`, etc. Never assume `lucide-react`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Icons in Button use data-icon attribute
|
||||||
|
|
||||||
|
Add `data-icon="inline-start"` (prefix) or `data-icon="inline-end"` (suffix) to the icon. No sizing classes on the icon.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button>
|
||||||
|
<SearchIcon className="mr-2 size-4" />
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button>
|
||||||
|
<SearchIcon data-icon="inline-start"/>
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button>
|
||||||
|
Next
|
||||||
|
<ArrowRightIcon data-icon="inline-end"/>
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No sizing classes on icons inside components
|
||||||
|
|
||||||
|
Components handle icon sizing via CSS. Don't add `size-4`, `w-4 h-4`, or other sizing classes to icons inside `Button`, `DropdownMenuItem`, `Alert`, `Sidebar*`, or other shadcn components. Unless the user explicitly asks for custom icon sizes.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button>
|
||||||
|
<SearchIcon className="size-4" data-icon="inline-start" />
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<SettingsIcon className="mr-2 size-4" />
|
||||||
|
Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button>
|
||||||
|
<SearchIcon data-icon="inline-start" />
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<SettingsIcon />
|
||||||
|
Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pass icons as component objects, not string keys
|
||||||
|
|
||||||
|
Use `icon={CheckIcon}`, not a string key to a lookup map.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const iconMap = {
|
||||||
|
check: CheckIcon,
|
||||||
|
alert: AlertIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatusBadge({ icon }: { icon: string }) {
|
||||||
|
const Icon = iconMap[icon];
|
||||||
|
return <Icon />;
|
||||||
|
}
|
||||||
|
|
||||||
|
<StatusBadge icon="check" />;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Import from the project's configured iconLibrary (e.g. lucide-react, @tabler/icons-react).
|
||||||
|
import { CheckIcon } from "lucide-react";
|
||||||
|
|
||||||
|
function StatusBadge({ icon: Icon }: { icon: React.ComponentType }) {
|
||||||
|
return <Icon />;
|
||||||
|
}
|
||||||
|
|
||||||
|
<StatusBadge icon={CheckIcon} />;
|
||||||
|
```
|
||||||
161
.agents/skills/shadcn/rules/styling.md
Normal file
161
.agents/skills/shadcn/rules/styling.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# Styling & Customization
|
||||||
|
|
||||||
|
See [customization.md](../customization.md) for theming, CSS variables, and adding custom colors.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Semantic colors
|
||||||
|
- Built-in variants first
|
||||||
|
- className for layout only
|
||||||
|
- No space-x-_ / space-y-_
|
||||||
|
- Prefer size-_ over w-_ h-\* when equal
|
||||||
|
- Prefer truncate shorthand
|
||||||
|
- No manual dark: color overrides
|
||||||
|
- Use cn() for conditional classes
|
||||||
|
- No manual z-index on overlay components
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Semantic colors
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="bg-blue-500 text-white">
|
||||||
|
<p className="text-gray-600">Secondary text</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="bg-primary text-primary-foreground">
|
||||||
|
<p className="text-muted-foreground">Secondary text</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No raw color values for status/state indicators
|
||||||
|
|
||||||
|
For positive, negative, or status indicators, use Badge variants, semantic tokens like `text-destructive`, or define custom CSS variables — don't reach for raw Tailwind colors.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<span className="text-emerald-600">+20.1%</span>
|
||||||
|
<span className="text-green-500">Active</span>
|
||||||
|
<span className="text-red-600">-3.2%</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Badge variant="secondary">+20.1%</Badge>
|
||||||
|
<Badge>Active</Badge>
|
||||||
|
<span className="text-destructive">-3.2%</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
If you need a success/positive color that doesn't exist as a semantic token, use a Badge variant or ask the user about adding a custom CSS variable to the theme (see [customization.md](../customization.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Built-in variants first
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button className="border border-input bg-transparent hover:bg-accent">Click me</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button variant="outline">Click me</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## className for layout only
|
||||||
|
|
||||||
|
Use `className` for layout (e.g. `max-w-md`, `mx-auto`, `mt-4`), **not** for overriding component colors or typography. To change colors, use semantic tokens, built-in variants, or CSS variables.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card className="bg-blue-100 text-blue-900 font-bold">
|
||||||
|
<CardContent>Dashboard</CardContent>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card className="max-w-md mx-auto">
|
||||||
|
<CardContent>Dashboard</CardContent>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
To customize a component's appearance, prefer these approaches in order:
|
||||||
|
|
||||||
|
1. **Built-in variants** — `variant="outline"`, `variant="destructive"`, etc.
|
||||||
|
2. **Semantic color tokens** — `bg-primary`, `text-muted-foreground`.
|
||||||
|
3. **CSS variables** — define custom colors in the global CSS file (see [customization.md](../customization.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No space-x-_ / space-y-_
|
||||||
|
|
||||||
|
Use `gap-*` instead. `space-y-4` → `flex flex-col gap-4`. `space-x-2` → `flex gap-2`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Input />
|
||||||
|
<Input />
|
||||||
|
<Button>Submit</Button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prefer size-_ over w-_ h-\* when equal
|
||||||
|
|
||||||
|
`size-10` not `w-10 h-10`. Applies to icons, avatars, skeletons, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prefer truncate shorthand
|
||||||
|
|
||||||
|
`truncate` not `overflow-hidden text-ellipsis whitespace-nowrap`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No manual dark: color overrides
|
||||||
|
|
||||||
|
Use semantic tokens — they handle light/dark via CSS variables. `bg-background text-foreground` not `bg-white dark:bg-gray-950`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use cn() for conditional classes
|
||||||
|
|
||||||
|
Use the `cn()` utility from the project for conditional or merged class names. Don't write manual ternaries in className strings.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className={`flex items-center ${isActive ? "bg-primary text-primary-foreground" : "bg-muted"}`}>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
<div className={cn("flex items-center", isActive ? "bg-primary text-primary-foreground" : "bg-muted")}>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No manual z-index on overlay components
|
||||||
|
|
||||||
|
`Dialog`, `Sheet`, `Drawer`, `AlertDialog`, `DropdownMenu`, `Popover`, `Tooltip`, `HoverCard` handle their own stacking. Never add `z-50` or `z-[999]`.
|
||||||
914
.agents/skills/turborepo/SKILL.md
Normal file
914
.agents/skills/turborepo/SKILL.md
Normal file
@@ -0,0 +1,914 @@
|
|||||||
|
---
|
||||||
|
name: turborepo
|
||||||
|
description: |
|
||||||
|
Turborepo monorepo build system guidance. Triggers on: turbo.json, task pipelines,
|
||||||
|
dependsOn, caching, remote cache, the "turbo" CLI, --filter, --affected, CI optimization, environment
|
||||||
|
variables, internal packages, monorepo structure/best practices, and boundaries.
|
||||||
|
|
||||||
|
Use when user: configures tasks/workflows/pipelines, creates packages, sets up
|
||||||
|
monorepo, shares code between apps, runs changed/affected packages, debugs cache,
|
||||||
|
or has apps/packages directories.
|
||||||
|
metadata:
|
||||||
|
version: 2.8.17-canary.13
|
||||||
|
---
|
||||||
|
|
||||||
|
# Turborepo Skill
|
||||||
|
|
||||||
|
Build system for JavaScript/TypeScript monorepos. Turborepo caches task outputs and runs tasks in parallel based on dependency graph.
|
||||||
|
|
||||||
|
## IMPORTANT: Package Tasks, Not Root Tasks
|
||||||
|
|
||||||
|
**DO NOT create Root Tasks. ALWAYS create package tasks.**
|
||||||
|
|
||||||
|
When creating tasks/scripts/pipelines, you MUST:
|
||||||
|
|
||||||
|
1. Add the script to each relevant package's `package.json`
|
||||||
|
2. Register the task in root `turbo.json`
|
||||||
|
3. Root `package.json` only delegates via `turbo run <task>`
|
||||||
|
|
||||||
|
**DO NOT** put task logic in root `package.json`. This defeats Turborepo's parallelization.
|
||||||
|
|
||||||
|
```json
|
||||||
|
// DO THIS: Scripts in each package
|
||||||
|
// apps/web/package.json
|
||||||
|
{ "scripts": { "build": "next build", "lint": "eslint .", "test": "vitest" } }
|
||||||
|
|
||||||
|
// apps/api/package.json
|
||||||
|
{ "scripts": { "build": "tsc", "lint": "eslint .", "test": "vitest" } }
|
||||||
|
|
||||||
|
// packages/ui/package.json
|
||||||
|
{ "scripts": { "build": "tsc", "lint": "eslint .", "test": "vitest" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
// turbo.json - register tasks
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
|
||||||
|
"lint": {},
|
||||||
|
"test": { "dependsOn": ["build"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Root package.json - ONLY delegates, no task logic
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"build": "turbo run build",
|
||||||
|
"lint": "turbo run lint",
|
||||||
|
"test": "turbo run test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
// DO NOT DO THIS - defeats parallelization
|
||||||
|
// Root package.json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"build": "cd apps/web && next build && cd ../api && tsc",
|
||||||
|
"lint": "eslint apps/ packages/",
|
||||||
|
"test": "vitest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Root Tasks (`//#taskname`) are ONLY for tasks that truly cannot exist in packages (rare).
|
||||||
|
|
||||||
|
## Secondary Rule: `turbo run` vs `turbo`
|
||||||
|
|
||||||
|
**Always use `turbo run` when the command is written into code:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
// package.json - ALWAYS "turbo run"
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"build": "turbo run build"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# CI workflows - ALWAYS "turbo run"
|
||||||
|
- run: turbo run build --affected
|
||||||
|
```
|
||||||
|
|
||||||
|
**The shorthand `turbo <tasks>` is ONLY for one-off terminal commands** typed directly by humans or agents. Never write `turbo build` into package.json, CI, or scripts.
|
||||||
|
|
||||||
|
## Quick Decision Trees
|
||||||
|
|
||||||
|
### "I need to configure a task"
|
||||||
|
|
||||||
|
```
|
||||||
|
Configure a task?
|
||||||
|
├─ Define task dependencies → references/configuration/tasks.md
|
||||||
|
├─ Lint/check-types (parallel + caching) → Use Transit Nodes pattern (see below)
|
||||||
|
├─ Specify build outputs → references/configuration/tasks.md#outputs
|
||||||
|
├─ Handle environment variables → references/environment/RULE.md
|
||||||
|
├─ Set up dev/watch tasks → references/configuration/tasks.md#persistent
|
||||||
|
├─ Package-specific config → references/configuration/RULE.md#package-configurations
|
||||||
|
└─ Global settings (cacheDir, daemon) → references/configuration/global-options.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### "My cache isn't working"
|
||||||
|
|
||||||
|
```
|
||||||
|
Cache problems?
|
||||||
|
├─ Tasks run but outputs not restored → Missing `outputs` key
|
||||||
|
├─ Cache misses unexpectedly → references/caching/gotchas.md
|
||||||
|
├─ Need to debug hash inputs → Use --summarize or --dry
|
||||||
|
├─ Want to skip cache entirely → Use --force or cache: false
|
||||||
|
├─ Remote cache not working → references/caching/remote-cache.md
|
||||||
|
└─ Environment causing misses → references/environment/gotchas.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### "I want to run only changed packages"
|
||||||
|
|
||||||
|
```
|
||||||
|
Run only what changed?
|
||||||
|
├─ Changed packages + dependents (RECOMMENDED) → turbo run build --affected
|
||||||
|
├─ Custom base branch → --affected --affected-base=origin/develop
|
||||||
|
├─ Manual git comparison → --filter=...[origin/main]
|
||||||
|
└─ See all filter options → references/filtering/RULE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**`--affected` is the primary way to run only changed packages.** It automatically compares against the default branch and includes dependents.
|
||||||
|
|
||||||
|
### "I want to filter packages"
|
||||||
|
|
||||||
|
```
|
||||||
|
Filter packages?
|
||||||
|
├─ Only changed packages → --affected (see above)
|
||||||
|
├─ By package name → --filter=web
|
||||||
|
├─ By directory → --filter=./apps/*
|
||||||
|
├─ Package + dependencies → --filter=web...
|
||||||
|
├─ Package + dependents → --filter=...web
|
||||||
|
└─ Complex combinations → references/filtering/patterns.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Environment variables aren't working"
|
||||||
|
|
||||||
|
```
|
||||||
|
Environment issues?
|
||||||
|
├─ Vars not available at runtime → Strict mode filtering (default)
|
||||||
|
├─ Cache hits with wrong env → Var not in `env` key
|
||||||
|
├─ .env changes not causing rebuilds → .env not in `inputs`
|
||||||
|
├─ CI variables missing → references/environment/gotchas.md
|
||||||
|
└─ Framework vars (NEXT_PUBLIC_*) → Auto-included via inference
|
||||||
|
```
|
||||||
|
|
||||||
|
### "I need to set up CI"
|
||||||
|
|
||||||
|
```
|
||||||
|
CI setup?
|
||||||
|
├─ GitHub Actions → references/ci/github-actions.md
|
||||||
|
├─ Vercel deployment → references/ci/vercel.md
|
||||||
|
├─ Remote cache in CI → references/caching/remote-cache.md
|
||||||
|
├─ Only build changed packages → --affected flag
|
||||||
|
├─ Skip unnecessary builds → turbo-ignore (references/cli/commands.md)
|
||||||
|
└─ Skip container setup when no changes → turbo-ignore
|
||||||
|
```
|
||||||
|
|
||||||
|
### "I want to watch for changes during development"
|
||||||
|
|
||||||
|
```
|
||||||
|
Watch mode?
|
||||||
|
├─ Re-run tasks on change → turbo watch (references/watch/RULE.md)
|
||||||
|
├─ Dev servers with dependencies → Use `with` key (references/configuration/tasks.md#with)
|
||||||
|
├─ Restart dev server on dep change → Use `interruptible: true`
|
||||||
|
└─ Persistent dev tasks → Use `persistent: true`
|
||||||
|
```
|
||||||
|
|
||||||
|
### "I need to create/structure a package"
|
||||||
|
|
||||||
|
```
|
||||||
|
Package creation/structure?
|
||||||
|
├─ Create an internal package → references/best-practices/packages.md
|
||||||
|
├─ Repository structure → references/best-practices/structure.md
|
||||||
|
├─ Dependency management → references/best-practices/dependencies.md
|
||||||
|
├─ Best practices overview → references/best-practices/RULE.md
|
||||||
|
├─ JIT vs Compiled packages → references/best-practices/packages.md#compilation-strategies
|
||||||
|
└─ Sharing code between apps → references/best-practices/RULE.md#package-types
|
||||||
|
```
|
||||||
|
|
||||||
|
### "How should I structure my monorepo?"
|
||||||
|
|
||||||
|
```
|
||||||
|
Monorepo structure?
|
||||||
|
├─ Standard layout (apps/, packages/) → references/best-practices/RULE.md
|
||||||
|
├─ Package types (apps vs libraries) → references/best-practices/RULE.md#package-types
|
||||||
|
├─ Creating internal packages → references/best-practices/packages.md
|
||||||
|
├─ TypeScript configuration → references/best-practices/structure.md#typescript-configuration
|
||||||
|
├─ ESLint configuration → references/best-practices/structure.md#eslint-configuration
|
||||||
|
├─ Dependency management → references/best-practices/dependencies.md
|
||||||
|
└─ Enforce package boundaries → references/boundaries/RULE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### "I want to enforce architectural boundaries"
|
||||||
|
|
||||||
|
```
|
||||||
|
Enforce boundaries?
|
||||||
|
├─ Check for violations → turbo boundaries
|
||||||
|
├─ Tag packages → references/boundaries/RULE.md#tags
|
||||||
|
├─ Restrict which packages can import others → references/boundaries/RULE.md#rule-types
|
||||||
|
└─ Prevent cross-package file imports → references/boundaries/RULE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Critical Anti-Patterns
|
||||||
|
|
||||||
|
### Using `turbo` Shorthand in Code
|
||||||
|
|
||||||
|
**`turbo run` is recommended in package.json scripts and CI pipelines.** The shorthand `turbo <task>` is intended for interactive terminal use.
|
||||||
|
|
||||||
|
```json
|
||||||
|
// WRONG - using shorthand in package.json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"build": "turbo build",
|
||||||
|
"dev": "turbo dev"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECT
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"build": "turbo run build",
|
||||||
|
"dev": "turbo run dev"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# WRONG - using shorthand in CI
|
||||||
|
- run: turbo build --affected
|
||||||
|
|
||||||
|
# CORRECT
|
||||||
|
- run: turbo run build --affected
|
||||||
|
```
|
||||||
|
|
||||||
|
### Root Scripts Bypassing Turbo
|
||||||
|
|
||||||
|
Root `package.json` scripts MUST delegate to `turbo run`, not run tasks directly.
|
||||||
|
|
||||||
|
```json
|
||||||
|
// WRONG - bypasses turbo entirely
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"build": "bun build",
|
||||||
|
"dev": "bun dev"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECT - delegates to turbo
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"build": "turbo run build",
|
||||||
|
"dev": "turbo run dev"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using `&&` to Chain Turbo Tasks
|
||||||
|
|
||||||
|
Don't chain turbo tasks with `&&`. Let turbo orchestrate.
|
||||||
|
|
||||||
|
```json
|
||||||
|
// WRONG - turbo task not using turbo run
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"changeset:publish": "bun build && changeset publish"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECT
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"changeset:publish": "turbo run build && changeset publish"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `prebuild` Scripts That Manually Build Dependencies
|
||||||
|
|
||||||
|
Scripts like `prebuild` that manually build other packages bypass Turborepo's dependency graph.
|
||||||
|
|
||||||
|
```json
|
||||||
|
// WRONG - manually building dependencies
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"prebuild": "cd ../../packages/types && bun run build && cd ../utils && bun run build",
|
||||||
|
"build": "next build"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**However, the fix depends on whether workspace dependencies are declared:**
|
||||||
|
|
||||||
|
1. **If dependencies ARE declared** (e.g., `"@repo/types": "workspace:*"` in package.json), remove the `prebuild` script. Turbo's `dependsOn: ["^build"]` handles this automatically.
|
||||||
|
|
||||||
|
2. **If dependencies are NOT declared**, the `prebuild` exists because `^build` won't trigger without a dependency relationship. The fix is to:
|
||||||
|
- Add the dependency to package.json: `"@repo/types": "workspace:*"`
|
||||||
|
- Then remove the `prebuild` script
|
||||||
|
|
||||||
|
```json
|
||||||
|
// CORRECT - declare dependency, let turbo handle build order
|
||||||
|
// package.json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@repo/types": "workspace:*",
|
||||||
|
"@repo/utils": "workspace:*"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "next build"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// turbo.json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key insight:** `^build` only runs build in packages listed as dependencies. No dependency declaration = no automatic build ordering.
|
||||||
|
|
||||||
|
### Overly Broad `globalDependencies`
|
||||||
|
|
||||||
|
`globalDependencies` affects ALL tasks in ALL packages. Be specific.
|
||||||
|
|
||||||
|
```json
|
||||||
|
// WRONG - heavy hammer, affects all hashes
|
||||||
|
{
|
||||||
|
"globalDependencies": ["**/.env.*local"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// BETTER - move to task-level inputs
|
||||||
|
{
|
||||||
|
"globalDependencies": [".env"],
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
||||||
|
"outputs": ["dist/**"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Repetitive Task Configuration
|
||||||
|
|
||||||
|
Look for repeated configuration across tasks that can be collapsed. Turborepo supports shared configuration patterns.
|
||||||
|
|
||||||
|
```json
|
||||||
|
// WRONG - repetitive env and inputs across tasks
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"env": ["API_URL", "DATABASE_URL"],
|
||||||
|
"inputs": ["$TURBO_DEFAULT$", ".env*"]
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"env": ["API_URL", "DATABASE_URL"],
|
||||||
|
"inputs": ["$TURBO_DEFAULT$", ".env*"]
|
||||||
|
},
|
||||||
|
"dev": {
|
||||||
|
"env": ["API_URL", "DATABASE_URL"],
|
||||||
|
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
||||||
|
"cache": false,
|
||||||
|
"persistent": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BETTER - use globalEnv and globalDependencies for shared config
|
||||||
|
{
|
||||||
|
"globalEnv": ["API_URL", "DATABASE_URL"],
|
||||||
|
"globalDependencies": [".env*"],
|
||||||
|
"tasks": {
|
||||||
|
"build": {},
|
||||||
|
"test": {},
|
||||||
|
"dev": {
|
||||||
|
"cache": false,
|
||||||
|
"persistent": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use global vs task-level:**
|
||||||
|
|
||||||
|
- `globalEnv` / `globalDependencies` - affects ALL tasks, use for truly shared config
|
||||||
|
- Task-level `env` / `inputs` - use when only specific tasks need it
|
||||||
|
|
||||||
|
### NOT an Anti-Pattern: Large `env` Arrays
|
||||||
|
|
||||||
|
A large `env` array (even 50+ variables) is **not** a problem. It usually means the user was thorough about declaring their build's environment dependencies. Do not flag this as an issue.
|
||||||
|
|
||||||
|
### Using `--parallel` Flag
|
||||||
|
|
||||||
|
The `--parallel` flag bypasses Turborepo's dependency graph. If tasks need parallel execution, configure `dependsOn` correctly instead.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# WRONG - bypasses dependency graph
|
||||||
|
turbo run lint --parallel
|
||||||
|
|
||||||
|
# CORRECT - configure tasks to allow parallel execution
|
||||||
|
# In turbo.json, set dependsOn appropriately (or use transit nodes)
|
||||||
|
turbo run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Package-Specific Task Overrides in Root turbo.json
|
||||||
|
|
||||||
|
When multiple packages need different task configurations, use **Package Configurations** (`turbo.json` in each package) instead of cluttering root `turbo.json` with `package#task` overrides.
|
||||||
|
|
||||||
|
```json
|
||||||
|
// WRONG - root turbo.json with many package-specific overrides
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"test": { "dependsOn": ["build"] },
|
||||||
|
"@repo/web#test": { "outputs": ["coverage/**"] },
|
||||||
|
"@repo/api#test": { "outputs": ["coverage/**"] },
|
||||||
|
"@repo/utils#test": { "outputs": [] },
|
||||||
|
"@repo/cli#test": { "outputs": [] },
|
||||||
|
"@repo/core#test": { "outputs": [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECT - use Package Configurations
|
||||||
|
// Root turbo.json - base config only
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"test": { "dependsOn": ["build"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// packages/web/turbo.json - package-specific override
|
||||||
|
{
|
||||||
|
"extends": ["//"],
|
||||||
|
"tasks": {
|
||||||
|
"test": { "outputs": ["coverage/**"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// packages/api/turbo.json
|
||||||
|
{
|
||||||
|
"extends": ["//"],
|
||||||
|
"tasks": {
|
||||||
|
"test": { "outputs": ["coverage/**"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits of Package Configurations:**
|
||||||
|
|
||||||
|
- Keeps configuration close to the code it affects
|
||||||
|
- Root turbo.json stays clean and focused on base patterns
|
||||||
|
- Easier to understand what's special about each package
|
||||||
|
- Works with `$TURBO_EXTENDS$` to inherit + extend arrays
|
||||||
|
|
||||||
|
**When to use `package#task` in root:**
|
||||||
|
|
||||||
|
- Single package needs a unique dependency (e.g., `"deploy": { "dependsOn": ["web#build"] }`)
|
||||||
|
- Temporary override while migrating
|
||||||
|
|
||||||
|
See `references/configuration/RULE.md#package-configurations` for full details.
|
||||||
|
|
||||||
|
### Using `../` to Traverse Out of Package in `inputs`
|
||||||
|
|
||||||
|
Don't use relative paths like `../` to reference files outside the package. Use `$TURBO_ROOT$` instead.
|
||||||
|
|
||||||
|
```json
|
||||||
|
// WRONG - traversing out of package
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"inputs": ["$TURBO_DEFAULT$", "../shared-config.json"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECT - use $TURBO_ROOT$ for repo root
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/shared-config.json"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Missing `outputs` for File-Producing Tasks
|
||||||
|
|
||||||
|
**Before flagging missing `outputs`, check what the task actually produces:**
|
||||||
|
|
||||||
|
1. Read the package's script (e.g., `"build": "tsc"`, `"test": "vitest"`)
|
||||||
|
2. Determine if it writes files to disk or only outputs to stdout
|
||||||
|
3. Only flag if the task produces files that should be cached
|
||||||
|
|
||||||
|
```json
|
||||||
|
// WRONG: build produces files but they're not cached
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECT: build outputs are cached
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": ["dist/**"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Common outputs by framework:
|
||||||
|
|
||||||
|
- Next.js: `[".next/**", "!.next/cache/**"]`
|
||||||
|
- Vite/Rollup: `["dist/**"]`
|
||||||
|
- tsc: `["dist/**"]` or custom `outDir`
|
||||||
|
|
||||||
|
**TypeScript `--noEmit` can still produce cache files:**
|
||||||
|
|
||||||
|
When `incremental: true` in tsconfig.json, `tsc --noEmit` writes `.tsbuildinfo` files even without emitting JS. Check the tsconfig before assuming no outputs:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// If tsconfig has incremental: true, tsc --noEmit produces cache files
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"typecheck": {
|
||||||
|
"outputs": ["node_modules/.cache/tsbuildinfo.json"] // or wherever tsBuildInfoFile points
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To determine correct outputs for TypeScript tasks:
|
||||||
|
|
||||||
|
1. Check if `incremental` or `composite` is enabled in tsconfig
|
||||||
|
2. Check `tsBuildInfoFile` for custom cache location (default: alongside `outDir` or in project root)
|
||||||
|
3. If no incremental mode, `tsc --noEmit` produces no files
|
||||||
|
|
||||||
|
### `^build` vs `build` Confusion
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
// ^build = run build in DEPENDENCIES first (other packages this one imports)
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"]
|
||||||
|
},
|
||||||
|
// build (no ^) = run build in SAME PACKAGE first
|
||||||
|
"test": {
|
||||||
|
"dependsOn": ["build"]
|
||||||
|
},
|
||||||
|
// pkg#task = specific package's task
|
||||||
|
"deploy": {
|
||||||
|
"dependsOn": ["web#build"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables Not Hashed
|
||||||
|
|
||||||
|
```json
|
||||||
|
// WRONG: API_URL changes won't cause rebuilds
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"outputs": ["dist/**"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECT: API_URL changes invalidate cache
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"outputs": ["dist/**"],
|
||||||
|
"env": ["API_URL", "API_KEY"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `.env` Files Not in Inputs
|
||||||
|
|
||||||
|
Turbo does NOT load `.env` files - your framework does. But Turbo needs to know about changes:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// WRONG: .env changes don't invalidate cache
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"env": ["API_URL"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECT: .env file changes invalidate cache
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"env": ["API_URL"],
|
||||||
|
"inputs": ["$TURBO_DEFAULT$", ".env", ".env.*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Root `.env` File in Monorepo
|
||||||
|
|
||||||
|
A `.env` file at the repo root is an anti-pattern — even for small monorepos or starter templates. It creates implicit coupling between packages and makes it unclear which packages depend on which variables.
|
||||||
|
|
||||||
|
```
|
||||||
|
// WRONG - root .env affects all packages implicitly
|
||||||
|
my-monorepo/
|
||||||
|
├── .env # Which packages use this?
|
||||||
|
├── apps/
|
||||||
|
│ ├── web/
|
||||||
|
│ └── api/
|
||||||
|
└── packages/
|
||||||
|
|
||||||
|
// CORRECT - .env files in packages that need them
|
||||||
|
my-monorepo/
|
||||||
|
├── apps/
|
||||||
|
│ ├── web/
|
||||||
|
│ │ └── .env # Clear: web needs DATABASE_URL
|
||||||
|
│ └── api/
|
||||||
|
│ └── .env # Clear: api needs API_KEY
|
||||||
|
└── packages/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problems with root `.env`:**
|
||||||
|
|
||||||
|
- Unclear which packages consume which variables
|
||||||
|
- All packages get all variables (even ones they don't need)
|
||||||
|
- Cache invalidation is coarse-grained (root .env change invalidates everything)
|
||||||
|
- Security risk: packages may accidentally access sensitive vars meant for others
|
||||||
|
- Bad habits start small — starter templates should model correct patterns
|
||||||
|
|
||||||
|
**If you must share variables**, use `globalEnv` to be explicit about what's shared, and document why.
|
||||||
|
|
||||||
|
### Strict Mode Filtering CI Variables
|
||||||
|
|
||||||
|
By default, Turborepo filters environment variables to only those in `env`/`globalEnv`. CI variables may be missing:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// If CI scripts need GITHUB_TOKEN but it's not in env:
|
||||||
|
{
|
||||||
|
"globalPassThroughEnv": ["GITHUB_TOKEN", "CI"],
|
||||||
|
"tasks": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use `--env-mode=loose` (not recommended for production).
|
||||||
|
|
||||||
|
### Shared Code in Apps (Should Be a Package)
|
||||||
|
|
||||||
|
```
|
||||||
|
// WRONG: Shared code inside an app
|
||||||
|
apps/
|
||||||
|
web/
|
||||||
|
shared/ # This breaks monorepo principles!
|
||||||
|
utils.ts
|
||||||
|
|
||||||
|
// CORRECT: Extract to a package
|
||||||
|
packages/
|
||||||
|
utils/
|
||||||
|
src/utils.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessing Files Across Package Boundaries
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// WRONG: Reaching into another package's internals
|
||||||
|
import { Button } from "../../packages/ui/src/button";
|
||||||
|
|
||||||
|
// CORRECT: Install and import properly
|
||||||
|
import { Button } from "@repo/ui/button";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Too Many Root Dependencies
|
||||||
|
|
||||||
|
```json
|
||||||
|
// WRONG: App dependencies in root
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18",
|
||||||
|
"next": "^14"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECT: Only repo tools in root
|
||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"turbo": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Task Configurations
|
||||||
|
|
||||||
|
### Standard Build Pipeline
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://v2-8-17-canary-13.turborepo.dev/schema.json",
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
|
||||||
|
},
|
||||||
|
"dev": {
|
||||||
|
"cache": false,
|
||||||
|
"persistent": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a `transit` task if you have tasks that need parallel execution with cache invalidation (see below).
|
||||||
|
|
||||||
|
### Dev Task with `^dev` Pattern (for `turbo watch`)
|
||||||
|
|
||||||
|
A `dev` task with `dependsOn: ["^dev"]` and `persistent: false` in root turbo.json may look unusual but is **correct for `turbo watch` workflows**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Root turbo.json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"dev": {
|
||||||
|
"dependsOn": ["^dev"],
|
||||||
|
"cache": false,
|
||||||
|
"persistent": false // Packages have one-shot dev scripts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Package turbo.json (apps/web/turbo.json)
|
||||||
|
{
|
||||||
|
"extends": ["//"],
|
||||||
|
"tasks": {
|
||||||
|
"dev": {
|
||||||
|
"persistent": true // Apps run long-running dev servers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this works:**
|
||||||
|
|
||||||
|
- **Packages** (e.g., `@acme/db`, `@acme/validators`) have `"dev": "tsc"` — one-shot type generation that completes quickly
|
||||||
|
- **Apps** override with `persistent: true` for actual dev servers (Next.js, etc.)
|
||||||
|
- **`turbo watch`** re-runs the one-shot package `dev` scripts when source files change, keeping types in sync
|
||||||
|
|
||||||
|
**Intended usage:** Run `turbo watch dev` (not `turbo run dev`). Watch mode re-executes one-shot tasks on file changes while keeping persistent tasks running.
|
||||||
|
|
||||||
|
**Alternative pattern:** Use a separate task name like `prepare` or `generate` for one-shot dependency builds to make the intent clearer:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"prepare": {
|
||||||
|
"dependsOn": ["^prepare"],
|
||||||
|
"outputs": ["dist/**"]
|
||||||
|
},
|
||||||
|
"dev": {
|
||||||
|
"dependsOn": ["prepare"],
|
||||||
|
"cache": false,
|
||||||
|
"persistent": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transit Nodes for Parallel Tasks with Cache Invalidation
|
||||||
|
|
||||||
|
Some tasks can run in parallel (don't need built output from dependencies) but must invalidate cache when dependency source code changes.
|
||||||
|
|
||||||
|
**The problem with `dependsOn: ["^taskname"]`:**
|
||||||
|
|
||||||
|
- Forces sequential execution (slow)
|
||||||
|
|
||||||
|
**The problem with `dependsOn: []` (no dependencies):**
|
||||||
|
|
||||||
|
- Allows parallel execution (fast)
|
||||||
|
- But cache is INCORRECT - changing dependency source won't invalidate cache
|
||||||
|
|
||||||
|
**Transit Nodes solve both:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"transit": { "dependsOn": ["^transit"] },
|
||||||
|
"my-task": { "dependsOn": ["transit"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `transit` task creates dependency relationships without matching any actual script, so tasks run in parallel with correct cache invalidation.
|
||||||
|
|
||||||
|
**How to identify tasks that need this pattern:** Look for tasks that read source files from dependencies but don't need their build outputs.
|
||||||
|
|
||||||
|
### With Environment Variables
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"globalEnv": ["NODE_ENV"],
|
||||||
|
"globalDependencies": [".env"],
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": ["dist/**"],
|
||||||
|
"env": ["API_URL", "DATABASE_URL"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reference Index
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| ------------------------------------------------------------------------------- | -------------------------------------------------------- |
|
||||||
|
| [configuration/RULE.md](./references/configuration/RULE.md) | turbo.json overview, Package Configurations |
|
||||||
|
| [configuration/tasks.md](./references/configuration/tasks.md) | dependsOn, outputs, inputs, env, cache, persistent |
|
||||||
|
| [configuration/global-options.md](./references/configuration/global-options.md) | globalEnv, globalDependencies, cacheDir, daemon, envMode |
|
||||||
|
| [configuration/gotchas.md](./references/configuration/gotchas.md) | Common configuration mistakes |
|
||||||
|
|
||||||
|
### Caching
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| --------------------------------------------------------------- | -------------------------------------------- |
|
||||||
|
| [caching/RULE.md](./references/caching/RULE.md) | How caching works, hash inputs |
|
||||||
|
| [caching/remote-cache.md](./references/caching/remote-cache.md) | Vercel Remote Cache, self-hosted, login/link |
|
||||||
|
| [caching/gotchas.md](./references/caching/gotchas.md) | Debugging cache misses, --summarize, --dry |
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| ------------------------------------------------------------- | ----------------------------------------- |
|
||||||
|
| [environment/RULE.md](./references/environment/RULE.md) | env, globalEnv, passThroughEnv |
|
||||||
|
| [environment/modes.md](./references/environment/modes.md) | Strict vs Loose mode, framework inference |
|
||||||
|
| [environment/gotchas.md](./references/environment/gotchas.md) | .env files, CI issues |
|
||||||
|
|
||||||
|
### Filtering
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| ----------------------------------------------------------- | ------------------------ |
|
||||||
|
| [filtering/RULE.md](./references/filtering/RULE.md) | --filter syntax overview |
|
||||||
|
| [filtering/patterns.md](./references/filtering/patterns.md) | Common filter patterns |
|
||||||
|
|
||||||
|
### CI/CD
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| --------------------------------------------------------- | ------------------------------- |
|
||||||
|
| [ci/RULE.md](./references/ci/RULE.md) | General CI principles |
|
||||||
|
| [ci/github-actions.md](./references/ci/github-actions.md) | Complete GitHub Actions setup |
|
||||||
|
| [ci/vercel.md](./references/ci/vercel.md) | Vercel deployment, turbo-ignore |
|
||||||
|
| [ci/patterns.md](./references/ci/patterns.md) | --affected, caching strategies |
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| ----------------------------------------------- | --------------------------------------------- |
|
||||||
|
| [cli/RULE.md](./references/cli/RULE.md) | turbo run basics |
|
||||||
|
| [cli/commands.md](./references/cli/commands.md) | turbo run flags, turbo-ignore, other commands |
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| ----------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
||||||
|
| [best-practices/RULE.md](./references/best-practices/RULE.md) | Monorepo best practices overview |
|
||||||
|
| [best-practices/structure.md](./references/best-practices/structure.md) | Repository structure, workspace config, TypeScript/ESLint setup |
|
||||||
|
| [best-practices/packages.md](./references/best-practices/packages.md) | Creating internal packages, JIT vs Compiled, exports |
|
||||||
|
| [best-practices/dependencies.md](./references/best-practices/dependencies.md) | Dependency management, installing, version sync |
|
||||||
|
|
||||||
|
### Watch Mode
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| ------------------------------------------- | ----------------------------------------------- |
|
||||||
|
| [watch/RULE.md](./references/watch/RULE.md) | turbo watch, interruptible tasks, dev workflows |
|
||||||
|
|
||||||
|
### Boundaries (Experimental)
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| ----------------------------------------------------- | ----------------------------------------------------- |
|
||||||
|
| [boundaries/RULE.md](./references/boundaries/RULE.md) | Enforce package isolation, tag-based dependency rules |
|
||||||
|
|
||||||
|
## Source Documentation
|
||||||
|
|
||||||
|
This skill is based on the official Turborepo documentation at:
|
||||||
|
|
||||||
|
- Source: `apps/docs/content/docs/` in the Turborepo repository
|
||||||
|
- Live: https://turborepo.dev/docs
|
||||||
70
.agents/skills/turborepo/command/turborepo.md
Normal file
70
.agents/skills/turborepo/command/turborepo.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
description: Load Turborepo skill for creating workflows, tasks, and pipelines in monorepos. Use when users ask to "create a workflow", "make a task", "generate a pipeline", or set up build orchestration.
|
||||||
|
---
|
||||||
|
|
||||||
|
Load the Turborepo skill and help with monorepo task orchestration: creating workflows, configuring tasks, setting up pipelines, and optimizing builds.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### Step 1: Load turborepo skill
|
||||||
|
|
||||||
|
```
|
||||||
|
skill({ name: 'turborepo' })
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Identify task type from user request
|
||||||
|
|
||||||
|
Analyze $ARGUMENTS to determine:
|
||||||
|
|
||||||
|
- **Topic**: configuration, caching, filtering, environment, CI, or CLI
|
||||||
|
- **Task type**: new setup, debugging, optimization, or implementation
|
||||||
|
|
||||||
|
Use decision trees in SKILL.md to select the relevant reference files.
|
||||||
|
|
||||||
|
### Step 3: Read relevant reference files
|
||||||
|
|
||||||
|
Based on task type, read from `references/<topic>/`:
|
||||||
|
|
||||||
|
| Task | Files to Read |
|
||||||
|
| -------------------- | ------------------------------------------------------- |
|
||||||
|
| Configure turbo.json | `configuration/RULE.md` + `configuration/tasks.md` |
|
||||||
|
| Debug cache issues | `caching/gotchas.md` |
|
||||||
|
| Set up remote cache | `caching/remote-cache.md` |
|
||||||
|
| Filter packages | `filtering/RULE.md` + `filtering/patterns.md` |
|
||||||
|
| Environment problems | `environment/gotchas.md` + `environment/modes.md` |
|
||||||
|
| Set up CI | `ci/RULE.md` + `ci/github-actions.md` or `ci/vercel.md` |
|
||||||
|
| CLI usage | `cli/commands.md` |
|
||||||
|
|
||||||
|
### Step 4: Execute task
|
||||||
|
|
||||||
|
Apply Turborepo-specific patterns from references to complete the user's request.
|
||||||
|
|
||||||
|
**CRITICAL - When creating tasks/scripts/pipelines:**
|
||||||
|
|
||||||
|
1. **DO NOT create Root Tasks** - Always create package tasks
|
||||||
|
2. Add scripts to each relevant package's `package.json` (e.g., `apps/web/package.json`, `packages/ui/package.json`)
|
||||||
|
3. Register the task in root `turbo.json`
|
||||||
|
4. Root `package.json` only contains `turbo run <task>` - never actual task logic
|
||||||
|
|
||||||
|
**Other things to verify:**
|
||||||
|
|
||||||
|
- `outputs` defined for cacheable tasks
|
||||||
|
- `dependsOn` uses correct syntax (`^task` vs `task`)
|
||||||
|
- Environment variables in `env` key
|
||||||
|
- `.env` files in `inputs` if used
|
||||||
|
- Use `turbo run` (not `turbo`) in package.json and CI
|
||||||
|
|
||||||
|
### Step 5: Summarize
|
||||||
|
|
||||||
|
```
|
||||||
|
=== Turborepo Task Complete ===
|
||||||
|
|
||||||
|
Topic: <configuration|caching|filtering|environment|ci|cli>
|
||||||
|
Files referenced: <reference files consulted>
|
||||||
|
|
||||||
|
<brief summary of what was done>
|
||||||
|
```
|
||||||
|
|
||||||
|
<user-request>
|
||||||
|
$ARGUMENTS
|
||||||
|
</user-request>
|
||||||
241
.agents/skills/turborepo/references/best-practices/RULE.md
Normal file
241
.agents/skills/turborepo/references/best-practices/RULE.md
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
# Monorepo Best Practices
|
||||||
|
|
||||||
|
Essential patterns for structuring and maintaining a healthy Turborepo monorepo.
|
||||||
|
|
||||||
|
## Repository Structure
|
||||||
|
|
||||||
|
### Standard Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
my-monorepo/
|
||||||
|
├── apps/ # Application packages (deployable)
|
||||||
|
│ ├── web/
|
||||||
|
│ ├── docs/
|
||||||
|
│ └── api/
|
||||||
|
├── packages/ # Library packages (shared code)
|
||||||
|
│ ├── ui/
|
||||||
|
│ ├── utils/
|
||||||
|
│ └── config-*/ # Shared configs (eslint, typescript, etc.)
|
||||||
|
├── package.json # Root package.json (minimal deps)
|
||||||
|
├── turbo.json # Turborepo configuration
|
||||||
|
├── pnpm-workspace.yaml # (pnpm) or workspaces in package.json
|
||||||
|
└── pnpm-lock.yaml # Lockfile (required)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Principles
|
||||||
|
|
||||||
|
1. **`apps/` for deployables**: Next.js sites, APIs, CLIs - things that get deployed
|
||||||
|
2. **`packages/` for libraries**: Shared code consumed by apps or other packages
|
||||||
|
3. **One purpose per package**: Each package should do one thing well
|
||||||
|
4. **No nested packages**: Don't put packages inside packages
|
||||||
|
|
||||||
|
## Package Types
|
||||||
|
|
||||||
|
### Application Packages (`apps/`)
|
||||||
|
|
||||||
|
- **Deployable**: These are the "endpoints" of your package graph
|
||||||
|
- **Not installed by other packages**: Apps shouldn't be dependencies of other packages
|
||||||
|
- **No shared code**: If code needs sharing, extract to `packages/`
|
||||||
|
|
||||||
|
```json
|
||||||
|
// apps/web/package.json
|
||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@repo/ui": "workspace:*",
|
||||||
|
"next": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Library Packages (`packages/`)
|
||||||
|
|
||||||
|
- **Shared code**: Utilities, components, configs
|
||||||
|
- **Namespaced names**: Use `@repo/` or `@yourorg/` prefix
|
||||||
|
- **Clear exports**: Define what the package exposes
|
||||||
|
|
||||||
|
```json
|
||||||
|
// packages/ui/package.json
|
||||||
|
{
|
||||||
|
"name": "@repo/ui",
|
||||||
|
"exports": {
|
||||||
|
"./button": "./src/button.tsx",
|
||||||
|
"./card": "./src/card.tsx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Package Compilation Strategies
|
||||||
|
|
||||||
|
### Just-in-Time (Simplest)
|
||||||
|
|
||||||
|
Export TypeScript directly; let the app's bundler compile it.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "@repo/ui",
|
||||||
|
"exports": {
|
||||||
|
"./button": "./src/button.tsx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**: Zero build config, instant changes
|
||||||
|
**Cons**: Can't cache builds, requires app bundler support
|
||||||
|
|
||||||
|
### Compiled (Recommended for Libraries)
|
||||||
|
|
||||||
|
Package compiles itself with `tsc` or bundler.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "@repo/ui",
|
||||||
|
"exports": {
|
||||||
|
"./button": {
|
||||||
|
"types": "./src/button.tsx",
|
||||||
|
"default": "./dist/button.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**: Cacheable by Turborepo, works everywhere
|
||||||
|
**Cons**: More configuration
|
||||||
|
|
||||||
|
## Dependency Management
|
||||||
|
|
||||||
|
### Install Where Used
|
||||||
|
|
||||||
|
Install dependencies in the package that uses them, not the root.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Good: Install in the package that needs it
|
||||||
|
pnpm add lodash --filter=@repo/utils
|
||||||
|
|
||||||
|
# Avoid: Installing everything at root
|
||||||
|
pnpm add lodash -w # Only for repo-level tools
|
||||||
|
```
|
||||||
|
|
||||||
|
### Root Dependencies
|
||||||
|
|
||||||
|
Only these belong in root `package.json`:
|
||||||
|
|
||||||
|
- `turbo` - The build system
|
||||||
|
- `husky`, `lint-staged` - Git hooks
|
||||||
|
- Repository-level tooling
|
||||||
|
|
||||||
|
### Internal Dependencies
|
||||||
|
|
||||||
|
Use workspace protocol for internal packages:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// pnpm/bun
|
||||||
|
{ "@repo/ui": "workspace:*" }
|
||||||
|
|
||||||
|
// npm/yarn
|
||||||
|
{ "@repo/ui": "*" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exports Best Practices
|
||||||
|
|
||||||
|
### Use `exports` Field (Not `main`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./button": "./src/button.tsx",
|
||||||
|
"./utils": "./src/utils.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Avoid Barrel Files
|
||||||
|
|
||||||
|
Don't create `index.ts` files that re-export everything:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// BAD: packages/ui/src/index.ts
|
||||||
|
export * from './button';
|
||||||
|
export * from './card';
|
||||||
|
export * from './modal';
|
||||||
|
// ... imports everything even if you need one thing
|
||||||
|
|
||||||
|
// GOOD: Direct exports in package.json
|
||||||
|
{
|
||||||
|
"exports": {
|
||||||
|
"./button": "./src/button.tsx",
|
||||||
|
"./card": "./src/card.tsx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Namespace Your Packages
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Good
|
||||||
|
{ "name": "@repo/ui" }
|
||||||
|
{ "name": "@acme/utils" }
|
||||||
|
|
||||||
|
// Avoid (conflicts with npm registry)
|
||||||
|
{ "name": "ui" }
|
||||||
|
{ "name": "utils" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Anti-Patterns
|
||||||
|
|
||||||
|
### Accessing Files Across Package Boundaries
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// BAD: Reaching into another package
|
||||||
|
import { Button } from "../../packages/ui/src/button";
|
||||||
|
|
||||||
|
// GOOD: Install and import properly
|
||||||
|
import { Button } from "@repo/ui/button";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shared Code in Apps
|
||||||
|
|
||||||
|
```
|
||||||
|
// BAD
|
||||||
|
apps/
|
||||||
|
web/
|
||||||
|
shared/ # This should be a package!
|
||||||
|
utils.ts
|
||||||
|
|
||||||
|
// GOOD
|
||||||
|
packages/
|
||||||
|
utils/ # Proper shared package
|
||||||
|
src/utils.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Too Many Root Dependencies
|
||||||
|
|
||||||
|
```json
|
||||||
|
// BAD: Root has app dependencies
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18",
|
||||||
|
"next": "^14",
|
||||||
|
"lodash": "^4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GOOD: Root only has repo tools
|
||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"turbo": "latest",
|
||||||
|
"husky": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [structure.md](./structure.md) - Detailed repository structure patterns
|
||||||
|
- [packages.md](./packages.md) - Creating and managing internal packages
|
||||||
|
- [dependencies.md](./dependencies.md) - Dependency management strategies
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
# Dependency Management
|
||||||
|
|
||||||
|
Best practices for managing dependencies in a Turborepo monorepo.
|
||||||
|
|
||||||
|
## Core Principle: Install Where Used
|
||||||
|
|
||||||
|
Dependencies belong in the package that uses them, not the root.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Good: Install in specific package
|
||||||
|
pnpm add react --filter=@repo/ui
|
||||||
|
pnpm add next --filter=web
|
||||||
|
|
||||||
|
# Avoid: Installing in root
|
||||||
|
pnpm add react -w # Only for repo-level tools!
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits of Local Installation
|
||||||
|
|
||||||
|
### 1. Clarity
|
||||||
|
|
||||||
|
Each package's `package.json` lists exactly what it needs:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// packages/ui/package.json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"class-variance-authority": "^0.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Flexibility
|
||||||
|
|
||||||
|
Different packages can use different versions when needed:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// packages/legacy-ui/package.json
|
||||||
|
{ "dependencies": { "react": "^17.0.0" } }
|
||||||
|
|
||||||
|
// packages/ui/package.json
|
||||||
|
{ "dependencies": { "react": "^18.0.0" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Better Caching
|
||||||
|
|
||||||
|
Installing in root changes workspace lockfile, invalidating all caches.
|
||||||
|
|
||||||
|
### 4. Pruning Support
|
||||||
|
|
||||||
|
`turbo prune` can remove unused dependencies for Docker images.
|
||||||
|
|
||||||
|
## What Belongs in Root
|
||||||
|
|
||||||
|
Only repository-level tools:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Root package.json
|
||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"turbo": "latest",
|
||||||
|
"husky": "^8.0.0",
|
||||||
|
"lint-staged": "^15.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**NOT** application dependencies:
|
||||||
|
|
||||||
|
- react, next, express
|
||||||
|
- lodash, axios, zod
|
||||||
|
- Testing libraries (unless truly repo-wide)
|
||||||
|
|
||||||
|
## Installing Dependencies
|
||||||
|
|
||||||
|
### Single Package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# pnpm
|
||||||
|
pnpm add lodash --filter=@repo/utils
|
||||||
|
|
||||||
|
# npm
|
||||||
|
npm install lodash --workspace=@repo/utils
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn workspace @repo/utils add lodash
|
||||||
|
|
||||||
|
# bun
|
||||||
|
cd packages/utils && bun add lodash
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Packages
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# pnpm
|
||||||
|
pnpm add jest --save-dev --filter=web --filter=@repo/ui
|
||||||
|
|
||||||
|
# npm
|
||||||
|
npm install jest --save-dev --workspace=web --workspace=@repo/ui
|
||||||
|
|
||||||
|
# yarn (v2+)
|
||||||
|
yarn workspaces foreach -R --from '{web,@repo/ui}' add jest --dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Internal Packages
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# pnpm
|
||||||
|
pnpm add @repo/ui --filter=web
|
||||||
|
|
||||||
|
# This updates package.json:
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@repo/ui": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Keeping Versions in Sync
|
||||||
|
|
||||||
|
### Option 1: Tooling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# syncpack - Check and fix version mismatches
|
||||||
|
npx syncpack list-mismatches
|
||||||
|
npx syncpack fix-mismatches
|
||||||
|
|
||||||
|
# manypkg - Similar functionality
|
||||||
|
npx @manypkg/cli check
|
||||||
|
npx @manypkg/cli fix
|
||||||
|
|
||||||
|
# sherif - Rust-based, very fast
|
||||||
|
npx sherif
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Package Manager Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# pnpm - Update everywhere
|
||||||
|
pnpm up --recursive typescript@latest
|
||||||
|
|
||||||
|
# npm - Update in all workspaces
|
||||||
|
npm install typescript@latest --workspaces
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: pnpm Catalogs (pnpm 9.5+)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# pnpm-workspace.yaml
|
||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
- "packages/*"
|
||||||
|
|
||||||
|
catalog:
|
||||||
|
react: ^18.2.0
|
||||||
|
typescript: ^5.3.0
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Any package.json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"react": "catalog:" // Uses version from catalog
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Internal vs External Dependencies
|
||||||
|
|
||||||
|
### Internal (Workspace)
|
||||||
|
|
||||||
|
```json
|
||||||
|
// pnpm/bun
|
||||||
|
{ "@repo/ui": "workspace:*" }
|
||||||
|
|
||||||
|
// npm/yarn
|
||||||
|
{ "@repo/ui": "*" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Turborepo understands these relationships and orders builds accordingly.
|
||||||
|
|
||||||
|
### External (npm Registry)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "lodash": "^4.17.21" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Standard semver versioning from npm.
|
||||||
|
|
||||||
|
## Peer Dependencies
|
||||||
|
|
||||||
|
For library packages that expect the consumer to provide dependencies:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// packages/ui/package.json
|
||||||
|
{
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"react-dom": "^18.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"react": "^18.0.0", // For development/testing
|
||||||
|
"react-dom": "^18.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### "Module not found"
|
||||||
|
|
||||||
|
1. Check the dependency is installed in the right package
|
||||||
|
2. Run `pnpm install` / `npm install` to update lockfile
|
||||||
|
3. Check exports are defined in the package
|
||||||
|
|
||||||
|
### Version Conflicts
|
||||||
|
|
||||||
|
Packages can use different versions - this is a feature, not a bug. But if you need consistency:
|
||||||
|
|
||||||
|
1. Use tooling (syncpack, manypkg)
|
||||||
|
2. Use pnpm catalogs
|
||||||
|
3. Create a lint rule
|
||||||
|
|
||||||
|
### Hoisting Issues
|
||||||
|
|
||||||
|
Some tools expect dependencies in specific locations. Use package manager config:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .npmrc (pnpm)
|
||||||
|
public-hoist-pattern[]=*eslint*
|
||||||
|
public-hoist-pattern[]=*prettier*
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lockfile
|
||||||
|
|
||||||
|
**Required** for:
|
||||||
|
|
||||||
|
- Reproducible builds
|
||||||
|
- Turborepo dependency analysis
|
||||||
|
- Cache correctness
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Commit your lockfile!
|
||||||
|
git add pnpm-lock.yaml # or package-lock.json, yarn.lock
|
||||||
|
```
|
||||||
335
.agents/skills/turborepo/references/best-practices/packages.md
Normal file
335
.agents/skills/turborepo/references/best-practices/packages.md
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
# Creating Internal Packages
|
||||||
|
|
||||||
|
How to create and structure internal packages in your monorepo.
|
||||||
|
|
||||||
|
## Package Creation Checklist
|
||||||
|
|
||||||
|
1. Create directory in `packages/`
|
||||||
|
2. Add `package.json` with name and exports
|
||||||
|
3. Add source code in `src/`
|
||||||
|
4. Add `tsconfig.json` if using TypeScript
|
||||||
|
5. Install as dependency in consuming packages
|
||||||
|
6. Run package manager install to update lockfile
|
||||||
|
|
||||||
|
## Package Compilation Strategies
|
||||||
|
|
||||||
|
### Just-in-Time (JIT)
|
||||||
|
|
||||||
|
Export TypeScript directly. The consuming app's bundler compiles it.
|
||||||
|
|
||||||
|
```json
|
||||||
|
// packages/ui/package.json
|
||||||
|
{
|
||||||
|
"name": "@repo/ui",
|
||||||
|
"exports": {
|
||||||
|
"./button": "./src/button.tsx",
|
||||||
|
"./card": "./src/card.tsx"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"lint": "eslint .",
|
||||||
|
"check-types": "tsc --noEmit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use:**
|
||||||
|
|
||||||
|
- Apps use modern bundlers (Turbopack, webpack, Vite)
|
||||||
|
- You want minimal configuration
|
||||||
|
- Build times are acceptable without caching
|
||||||
|
|
||||||
|
**Limitations:**
|
||||||
|
|
||||||
|
- No Turborepo cache for the package itself
|
||||||
|
- Consumer must support TypeScript compilation
|
||||||
|
- Can't use TypeScript `paths` (use Node.js subpath imports instead)
|
||||||
|
|
||||||
|
### Compiled
|
||||||
|
|
||||||
|
Package handles its own compilation.
|
||||||
|
|
||||||
|
```json
|
||||||
|
// packages/ui/package.json
|
||||||
|
{
|
||||||
|
"name": "@repo/ui",
|
||||||
|
"exports": {
|
||||||
|
"./button": {
|
||||||
|
"types": "./src/button.tsx",
|
||||||
|
"default": "./dist/button.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "tsc --watch"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
// packages/ui/tsconfig.json
|
||||||
|
{
|
||||||
|
"extends": "@repo/typescript-config/library.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use:**
|
||||||
|
|
||||||
|
- You want Turborepo to cache builds
|
||||||
|
- Package will be used by non-bundler tools
|
||||||
|
- You need maximum compatibility
|
||||||
|
|
||||||
|
**Remember:** Add `dist/**` to turbo.json outputs!
|
||||||
|
|
||||||
|
## Defining Exports
|
||||||
|
|
||||||
|
### Multiple Entrypoints
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts", // @repo/ui
|
||||||
|
"./button": "./src/button.tsx", // @repo/ui/button
|
||||||
|
"./card": "./src/card.tsx", // @repo/ui/card
|
||||||
|
"./hooks": "./src/hooks/index.ts" // @repo/ui/hooks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional Exports (Compiled)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exports": {
|
||||||
|
"./button": {
|
||||||
|
"types": "./src/button.tsx",
|
||||||
|
"import": "./dist/button.mjs",
|
||||||
|
"require": "./dist/button.cjs",
|
||||||
|
"default": "./dist/button.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installing Internal Packages
|
||||||
|
|
||||||
|
### Add to Consuming Package
|
||||||
|
|
||||||
|
```json
|
||||||
|
// apps/web/package.json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@repo/ui": "workspace:*" // pnpm/bun
|
||||||
|
// "@repo/ui": "*" // npm/yarn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install # Updates lockfile with new dependency
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import and Use
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/web/src/page.tsx
|
||||||
|
import { Button } from '@repo/ui/button';
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <Button>Click me</Button>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## One Purpose Per Package
|
||||||
|
|
||||||
|
### Good Examples
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/
|
||||||
|
├── ui/ # Shared UI components
|
||||||
|
├── utils/ # General utilities
|
||||||
|
├── auth/ # Authentication logic
|
||||||
|
├── database/ # Database client/schemas
|
||||||
|
├── eslint-config/ # ESLint configuration
|
||||||
|
├── typescript-config/ # TypeScript configuration
|
||||||
|
└── api-client/ # Generated API client
|
||||||
|
```
|
||||||
|
|
||||||
|
### Avoid Mega-Packages
|
||||||
|
|
||||||
|
```
|
||||||
|
// BAD: One package for everything
|
||||||
|
packages/
|
||||||
|
└── shared/
|
||||||
|
├── components/
|
||||||
|
├── utils/
|
||||||
|
├── hooks/
|
||||||
|
├── types/
|
||||||
|
└── api/
|
||||||
|
|
||||||
|
// GOOD: Separate by purpose
|
||||||
|
packages/
|
||||||
|
├── ui/ # Components
|
||||||
|
├── utils/ # Utilities
|
||||||
|
├── hooks/ # React hooks
|
||||||
|
├── types/ # Shared TypeScript types
|
||||||
|
└── api-client/ # API utilities
|
||||||
|
```
|
||||||
|
|
||||||
|
## Config Packages
|
||||||
|
|
||||||
|
### TypeScript Config
|
||||||
|
|
||||||
|
```json
|
||||||
|
// packages/typescript-config/package.json
|
||||||
|
{
|
||||||
|
"name": "@repo/typescript-config",
|
||||||
|
"exports": {
|
||||||
|
"./base.json": "./base.json",
|
||||||
|
"./nextjs.json": "./nextjs.json",
|
||||||
|
"./library.json": "./library.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ESLint Config
|
||||||
|
|
||||||
|
```json
|
||||||
|
// packages/eslint-config/package.json
|
||||||
|
{
|
||||||
|
"name": "@repo/eslint-config",
|
||||||
|
"exports": {
|
||||||
|
"./base": "./base.js",
|
||||||
|
"./next": "./next.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"eslint": "^8.0.0",
|
||||||
|
"eslint-config-next": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
### Forgetting to Export
|
||||||
|
|
||||||
|
```json
|
||||||
|
// BAD: No exports defined
|
||||||
|
{
|
||||||
|
"name": "@repo/ui"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GOOD: Clear exports
|
||||||
|
{
|
||||||
|
"name": "@repo/ui",
|
||||||
|
"exports": {
|
||||||
|
"./button": "./src/button.tsx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wrong Workspace Syntax
|
||||||
|
|
||||||
|
```json
|
||||||
|
// pnpm/bun
|
||||||
|
{ "@repo/ui": "workspace:*" } // Correct
|
||||||
|
|
||||||
|
// npm/yarn
|
||||||
|
{ "@repo/ui": "*" } // Correct
|
||||||
|
{ "@repo/ui": "workspace:*" } // Wrong for npm/yarn!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Missing from turbo.json Outputs
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Package builds to dist/, but turbo.json doesn't know
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"outputs": [".next/**"] // Missing dist/**!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correct
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"outputs": [".next/**", "dist/**"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## TypeScript Best Practices
|
||||||
|
|
||||||
|
### Use Node.js Subpath Imports (Not `paths`)
|
||||||
|
|
||||||
|
TypeScript `compilerOptions.paths` breaks with JIT packages. Use Node.js subpath imports instead (TypeScript 5.4+).
|
||||||
|
|
||||||
|
**JIT Package:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
// packages/ui/package.json
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"#*": "./src/*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/ui/button.tsx
|
||||||
|
import { MY_STRING } from "#utils.ts"; // Uses .ts extension
|
||||||
|
```
|
||||||
|
|
||||||
|
**Compiled Package:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
// packages/ui/package.json
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"#*": "./dist/*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/ui/button.tsx
|
||||||
|
import { MY_STRING } from "#utils.js"; // Uses .js extension
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use `tsc` for Internal Packages
|
||||||
|
|
||||||
|
For internal packages, prefer `tsc` over bundlers. Bundlers can mangle code before it reaches your app's bundler, causing hard-to-debug issues.
|
||||||
|
|
||||||
|
### Enable Go-to-Definition
|
||||||
|
|
||||||
|
For Compiled Packages, enable declaration maps:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// tsconfig.json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates `.d.ts` and `.d.ts.map` files for IDE navigation.
|
||||||
|
|
||||||
|
### No Root tsconfig.json Needed
|
||||||
|
|
||||||
|
Each package should have its own `tsconfig.json`. A root one causes all tasks to miss cache when changed. Only use root `tsconfig.json` for non-package scripts.
|
||||||
|
|
||||||
|
### Avoid TypeScript Project References
|
||||||
|
|
||||||
|
They add complexity and another caching layer. Turborepo handles dependencies better.
|
||||||
270
.agents/skills/turborepo/references/best-practices/structure.md
Normal file
270
.agents/skills/turborepo/references/best-practices/structure.md
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
# Repository Structure
|
||||||
|
|
||||||
|
Detailed guidance on structuring a Turborepo monorepo.
|
||||||
|
|
||||||
|
## Workspace Configuration
|
||||||
|
|
||||||
|
### pnpm (Recommended)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# pnpm-workspace.yaml
|
||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
- "packages/*"
|
||||||
|
```
|
||||||
|
|
||||||
|
### npm/yarn/bun
|
||||||
|
|
||||||
|
```json
|
||||||
|
// package.json
|
||||||
|
{
|
||||||
|
"workspaces": ["apps/*", "packages/*"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Root package.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-monorepo",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "pnpm@9.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"build": "turbo run build",
|
||||||
|
"dev": "turbo run dev",
|
||||||
|
"lint": "turbo run lint",
|
||||||
|
"test": "turbo run test"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"turbo": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
|
||||||
|
- `private: true` - Prevents accidental publishing
|
||||||
|
- `packageManager` - Enforces consistent package manager version
|
||||||
|
- **Scripts only delegate to `turbo run`** - No actual build logic here!
|
||||||
|
- Minimal devDependencies (just turbo and repo tools)
|
||||||
|
|
||||||
|
## Always Prefer Package Tasks
|
||||||
|
|
||||||
|
**Always use package tasks. Only use Root Tasks if you cannot succeed with package tasks.**
|
||||||
|
|
||||||
|
```json
|
||||||
|
// packages/web/package.json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"build": "next build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"test": "vitest",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// packages/api/package.json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"test": "vitest",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Package tasks enable Turborepo to:
|
||||||
|
|
||||||
|
1. **Parallelize** - Run `web#lint` and `api#lint` simultaneously
|
||||||
|
2. **Cache individually** - Each package's task output is cached separately
|
||||||
|
3. **Filter precisely** - Run `turbo run test --filter=web` for just one package
|
||||||
|
|
||||||
|
**Root Tasks are a fallback** for tasks that truly cannot run per-package:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// AVOID unless necessary - sequential, not parallelized, can't filter
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"lint": "eslint apps/web && eslint apps/api && eslint packages/ui"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Root turbo.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://v2-8-17-canary-13.turborepo.dev/schema.json",
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
|
||||||
|
},
|
||||||
|
"lint": {},
|
||||||
|
"test": {
|
||||||
|
"dependsOn": ["build"]
|
||||||
|
},
|
||||||
|
"dev": {
|
||||||
|
"cache": false,
|
||||||
|
"persistent": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Directory Organization
|
||||||
|
|
||||||
|
### Grouping Packages
|
||||||
|
|
||||||
|
You can group packages by adding more workspace paths:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# pnpm-workspace.yaml
|
||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
- "packages/*"
|
||||||
|
- "packages/config/*" # Grouped configs
|
||||||
|
- "packages/features/*" # Feature packages
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows:
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/
|
||||||
|
├── ui/
|
||||||
|
├── utils/
|
||||||
|
├── config/
|
||||||
|
│ ├── eslint/
|
||||||
|
│ ├── typescript/
|
||||||
|
│ └── tailwind/
|
||||||
|
└── features/
|
||||||
|
├── auth/
|
||||||
|
└── payments/
|
||||||
|
```
|
||||||
|
|
||||||
|
### What NOT to Do
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# BAD: Nested wildcards cause ambiguous behavior
|
||||||
|
packages:
|
||||||
|
- "packages/**" # Don't do this!
|
||||||
|
```
|
||||||
|
|
||||||
|
## Package Anatomy
|
||||||
|
|
||||||
|
### Minimum Required Files
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/ui/
|
||||||
|
├── package.json # Required: Makes it a package
|
||||||
|
├── src/ # Source code
|
||||||
|
│ └── button.tsx
|
||||||
|
└── tsconfig.json # TypeScript config (if using TS)
|
||||||
|
```
|
||||||
|
|
||||||
|
### package.json Requirements
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "@repo/ui", // Unique, namespaced name
|
||||||
|
"version": "0.0.0", // Version (can be 0.0.0 for internal)
|
||||||
|
"private": true, // Prevents accidental publishing
|
||||||
|
"exports": {
|
||||||
|
// Entry points
|
||||||
|
"./button": "./src/button.tsx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## TypeScript Configuration
|
||||||
|
|
||||||
|
### Shared Base Config
|
||||||
|
|
||||||
|
Create a shared TypeScript config package:
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/
|
||||||
|
└── typescript-config/
|
||||||
|
├── package.json
|
||||||
|
├── base.json
|
||||||
|
├── nextjs.json
|
||||||
|
└── library.json
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
// packages/typescript-config/base.json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"module": "ESNext",
|
||||||
|
"target": "ES2022"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extending in Packages
|
||||||
|
|
||||||
|
```json
|
||||||
|
// packages/ui/tsconfig.json
|
||||||
|
{
|
||||||
|
"extends": "@repo/typescript-config/library.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### No Root tsconfig.json
|
||||||
|
|
||||||
|
You likely don't need a `tsconfig.json` in the workspace root. Each package should have its own config extending from the shared config package.
|
||||||
|
|
||||||
|
## ESLint Configuration
|
||||||
|
|
||||||
|
### Shared Config Package
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/
|
||||||
|
└── eslint-config/
|
||||||
|
├── package.json
|
||||||
|
├── base.js
|
||||||
|
├── next.js
|
||||||
|
└── library.js
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
// packages/eslint-config/package.json
|
||||||
|
{
|
||||||
|
"name": "@repo/eslint-config",
|
||||||
|
"exports": {
|
||||||
|
"./base": "./base.js",
|
||||||
|
"./next": "./next.js",
|
||||||
|
"./library": "./library.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using in Packages
|
||||||
|
|
||||||
|
```js
|
||||||
|
// apps/web/.eslintrc.js
|
||||||
|
module.exports = {
|
||||||
|
extends: ["@repo/eslint-config/next"],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lockfile
|
||||||
|
|
||||||
|
A lockfile is **required** for:
|
||||||
|
|
||||||
|
- Reproducible builds
|
||||||
|
- Turborepo to understand package dependencies
|
||||||
|
- Cache correctness
|
||||||
|
|
||||||
|
Without a lockfile, you'll see unpredictable behavior.
|
||||||
126
.agents/skills/turborepo/references/boundaries/RULE.md
Normal file
126
.agents/skills/turborepo/references/boundaries/RULE.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# Boundaries
|
||||||
|
|
||||||
|
**Experimental feature** - See [RFC](https://github.com/vercel/turborepo/discussions/9435)
|
||||||
|
|
||||||
|
Full docs: https://turborepo.dev/docs/reference/boundaries
|
||||||
|
|
||||||
|
Boundaries enforce package isolation by detecting:
|
||||||
|
|
||||||
|
1. Imports of files outside the package's directory
|
||||||
|
2. Imports of packages not declared in `package.json` dependencies
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo boundaries
|
||||||
|
```
|
||||||
|
|
||||||
|
Run this to check for workspace violations across your monorepo.
|
||||||
|
|
||||||
|
## Tags
|
||||||
|
|
||||||
|
Tags allow you to create rules for which packages can depend on each other.
|
||||||
|
|
||||||
|
### Adding Tags to a Package
|
||||||
|
|
||||||
|
```json
|
||||||
|
// packages/ui/turbo.json
|
||||||
|
{
|
||||||
|
"tags": ["internal"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuring Tag Rules
|
||||||
|
|
||||||
|
Rules go in root `turbo.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// turbo.json
|
||||||
|
{
|
||||||
|
"boundaries": {
|
||||||
|
"tags": {
|
||||||
|
"public": {
|
||||||
|
"dependencies": {
|
||||||
|
"deny": ["internal"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This prevents `public`-tagged packages from importing `internal`-tagged packages.
|
||||||
|
|
||||||
|
### Rule Types
|
||||||
|
|
||||||
|
**Allow-list approach** (only allow specific tags):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"boundaries": {
|
||||||
|
"tags": {
|
||||||
|
"public": {
|
||||||
|
"dependencies": {
|
||||||
|
"allow": ["public"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deny-list approach** (block specific tags):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"boundaries": {
|
||||||
|
"tags": {
|
||||||
|
"public": {
|
||||||
|
"dependencies": {
|
||||||
|
"deny": ["internal"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Restrict dependents** (who can import this package):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"boundaries": {
|
||||||
|
"tags": {
|
||||||
|
"private": {
|
||||||
|
"dependents": {
|
||||||
|
"deny": ["public"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Package Names
|
||||||
|
|
||||||
|
Package names work in place of tags:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"boundaries": {
|
||||||
|
"tags": {
|
||||||
|
"private": {
|
||||||
|
"dependents": {
|
||||||
|
"deny": ["@repo/my-pkg"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Points
|
||||||
|
|
||||||
|
- Rules apply transitively (dependencies of dependencies)
|
||||||
|
- Helps enforce architectural boundaries at scale
|
||||||
|
- Catches violations before runtime/build errors
|
||||||
107
.agents/skills/turborepo/references/caching/RULE.md
Normal file
107
.agents/skills/turborepo/references/caching/RULE.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# How Turborepo Caching Works
|
||||||
|
|
||||||
|
Turborepo's core principle: **never do the same work twice**.
|
||||||
|
|
||||||
|
## The Cache Equation
|
||||||
|
|
||||||
|
```
|
||||||
|
fingerprint(inputs) → stored outputs
|
||||||
|
```
|
||||||
|
|
||||||
|
If inputs haven't changed, restore outputs from cache instead of re-running the task.
|
||||||
|
|
||||||
|
## What Determines the Cache Key
|
||||||
|
|
||||||
|
### Global Hash Inputs
|
||||||
|
|
||||||
|
These affect ALL tasks in the repo:
|
||||||
|
|
||||||
|
- `package-lock.json` / `yarn.lock` / `pnpm-lock.yaml`
|
||||||
|
- Files listed in `globalDependencies`
|
||||||
|
- Environment variables in `globalEnv`
|
||||||
|
- `turbo.json` configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"globalDependencies": [".env", "tsconfig.base.json"],
|
||||||
|
"globalEnv": ["CI", "NODE_ENV"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task Hash Inputs
|
||||||
|
|
||||||
|
These affect specific tasks:
|
||||||
|
|
||||||
|
- All files in the package (unless filtered by `inputs`)
|
||||||
|
- `package.json` contents
|
||||||
|
- Environment variables in task's `env` key
|
||||||
|
- Task configuration (command, outputs, dependencies)
|
||||||
|
- Hashes of dependent tasks (`dependsOn`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"inputs": ["src/**", "package.json", "tsconfig.json"],
|
||||||
|
"env": ["API_URL"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## What Gets Cached
|
||||||
|
|
||||||
|
1. **File outputs** - files/directories specified in `outputs`
|
||||||
|
2. **Task logs** - stdout/stderr for replay on cache hit
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"outputs": ["dist/**", ".next/**"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local Cache Location
|
||||||
|
|
||||||
|
```
|
||||||
|
.turbo/cache/
|
||||||
|
├── <hash1>.tar.zst # compressed outputs
|
||||||
|
├── <hash2>.tar.zst
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `.turbo` to `.gitignore`.
|
||||||
|
|
||||||
|
## Cache Restoration
|
||||||
|
|
||||||
|
On cache hit, Turborepo:
|
||||||
|
|
||||||
|
1. Extracts archived outputs to their original locations
|
||||||
|
2. Replays the logged stdout/stderr
|
||||||
|
3. Reports the task as cached (shows `FULL TURBO` in output)
|
||||||
|
|
||||||
|
## Example Flow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# First run - executes build, caches result
|
||||||
|
turbo build
|
||||||
|
# → packages/ui: cache miss, executing...
|
||||||
|
# → packages/web: cache miss, executing...
|
||||||
|
|
||||||
|
# Second run - same inputs, restores from cache
|
||||||
|
turbo build
|
||||||
|
# → packages/ui: cache hit, replaying output
|
||||||
|
# → packages/web: cache hit, replaying output
|
||||||
|
# → FULL TURBO
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Points
|
||||||
|
|
||||||
|
- Cache is content-addressed (based on input hash, not timestamps)
|
||||||
|
- Empty `outputs` array means task runs but nothing is cached
|
||||||
|
- Tasks without `outputs` key cache nothing (use `"outputs": []` to be explicit)
|
||||||
|
- Cache is invalidated when ANY input changes
|
||||||
169
.agents/skills/turborepo/references/caching/gotchas.md
Normal file
169
.agents/skills/turborepo/references/caching/gotchas.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# Debugging Cache Issues
|
||||||
|
|
||||||
|
## Diagnostic Tools
|
||||||
|
|
||||||
|
### `--summarize`
|
||||||
|
|
||||||
|
Generates a JSON file with all hash inputs. Compare two runs to find differences.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo build --summarize
|
||||||
|
# Creates .turbo/runs/<run-id>.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The summary includes:
|
||||||
|
|
||||||
|
- Global hash and its inputs
|
||||||
|
- Per-task hashes and their inputs
|
||||||
|
- Environment variables that affected the hash
|
||||||
|
|
||||||
|
**Comparing runs:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run twice, compare the summaries
|
||||||
|
diff .turbo/runs/<first-run>.json .turbo/runs/<second-run>.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### `--dry` / `--dry=json`
|
||||||
|
|
||||||
|
See what would run without executing anything:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo build --dry
|
||||||
|
turbo build --dry=json # machine-readable output
|
||||||
|
```
|
||||||
|
|
||||||
|
Shows cache status for each task without running them.
|
||||||
|
|
||||||
|
### `--force`
|
||||||
|
|
||||||
|
Skip reading cache, re-execute all tasks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo build --force
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful to verify tasks actually work (not just cached results).
|
||||||
|
|
||||||
|
## Unexpected Cache Misses
|
||||||
|
|
||||||
|
**Symptom:** Task runs when you expected a cache hit.
|
||||||
|
|
||||||
|
### Environment Variable Changed
|
||||||
|
|
||||||
|
Check if an env var in the `env` key changed:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"env": ["API_URL", "NODE_ENV"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Different `API_URL` between runs = cache miss.
|
||||||
|
|
||||||
|
### .env File Changed
|
||||||
|
|
||||||
|
`.env` files aren't tracked by default. Add to `inputs`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"inputs": ["$TURBO_DEFAULT$", ".env", ".env.local"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use `globalDependencies` for repo-wide env files:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"globalDependencies": [".env"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lockfile Changed
|
||||||
|
|
||||||
|
Installing/updating packages changes the global hash.
|
||||||
|
|
||||||
|
### Source Files Changed
|
||||||
|
|
||||||
|
Any file in the package (or in `inputs`) triggers a miss.
|
||||||
|
|
||||||
|
### turbo.json Changed
|
||||||
|
|
||||||
|
Config changes invalidate the global hash.
|
||||||
|
|
||||||
|
## Incorrect Cache Hits
|
||||||
|
|
||||||
|
**Symptom:** Cached output is stale/wrong.
|
||||||
|
|
||||||
|
### Missing Environment Variable
|
||||||
|
|
||||||
|
Task uses an env var not listed in `env`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// build.js
|
||||||
|
const apiUrl = process.env.API_URL; // not tracked!
|
||||||
|
```
|
||||||
|
|
||||||
|
Fix: add to task config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"env": ["API_URL"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Missing File in Inputs
|
||||||
|
|
||||||
|
Task reads a file outside default inputs:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"inputs": [
|
||||||
|
"$TURBO_DEFAULT$",
|
||||||
|
"../../shared-config.json" // file outside package
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Useful Flags
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Only show output for cache misses
|
||||||
|
turbo build --output-logs=new-only
|
||||||
|
|
||||||
|
# Show output for everything (debugging)
|
||||||
|
turbo build --output-logs=full
|
||||||
|
|
||||||
|
# See why tasks are running
|
||||||
|
turbo build --verbosity=2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Checklist
|
||||||
|
|
||||||
|
Cache miss when expected hit:
|
||||||
|
|
||||||
|
1. Run with `--summarize`, compare with previous run
|
||||||
|
2. Check env vars with `--dry=json`
|
||||||
|
3. Look for lockfile/config changes in git
|
||||||
|
|
||||||
|
Cache hit when expected miss:
|
||||||
|
|
||||||
|
1. Verify env var is in `env` array
|
||||||
|
2. Verify file is in `inputs` array
|
||||||
|
3. Check if file is outside package directory
|
||||||
127
.agents/skills/turborepo/references/caching/remote-cache.md
Normal file
127
.agents/skills/turborepo/references/caching/remote-cache.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# Remote Caching
|
||||||
|
|
||||||
|
Share cache artifacts across your team and CI pipelines.
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
- Team members get cache hits from each other's work
|
||||||
|
- CI gets cache hits from local development (and vice versa)
|
||||||
|
- Dramatically faster CI runs after first build
|
||||||
|
- No more "works on my machine" rebuilds
|
||||||
|
|
||||||
|
## Vercel Remote Cache
|
||||||
|
|
||||||
|
Free, zero-config when deploying on Vercel. For local dev and other CI:
|
||||||
|
|
||||||
|
### Local Development Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Authenticate with Vercel
|
||||||
|
npx turbo login
|
||||||
|
|
||||||
|
# Link repo to your Vercel team
|
||||||
|
npx turbo link
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates `.turbo/config.json` with your team info (gitignored by default).
|
||||||
|
|
||||||
|
### CI Setup
|
||||||
|
|
||||||
|
Set these environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TURBO_TOKEN=<your-token>
|
||||||
|
TURBO_TEAM=<your-team-slug>
|
||||||
|
```
|
||||||
|
|
||||||
|
Get your token from Vercel dashboard → Settings → Tokens.
|
||||||
|
|
||||||
|
**GitHub Actions example:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Build
|
||||||
|
run: npx turbo build
|
||||||
|
env:
|
||||||
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration in turbo.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"remoteCache": {
|
||||||
|
"enabled": true,
|
||||||
|
"signature": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
|
||||||
|
- `enabled`: toggle remote cache (default: true when authenticated)
|
||||||
|
- `signature`: require artifact signing (default: false)
|
||||||
|
|
||||||
|
## Artifact Signing
|
||||||
|
|
||||||
|
Verify cache artifacts haven't been tampered with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set a secret key (use same key across all environments)
|
||||||
|
export TURBO_REMOTE_CACHE_SIGNATURE_KEY="your-secret-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable in config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"remoteCache": {
|
||||||
|
"signature": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Signed artifacts can only be restored if the signature matches.
|
||||||
|
|
||||||
|
## Self-Hosted Options
|
||||||
|
|
||||||
|
Community implementations for running your own cache server:
|
||||||
|
|
||||||
|
- **turbo-remote-cache** (Node.js) - supports S3, GCS, Azure
|
||||||
|
- **turborepo-remote-cache** (Go) - lightweight, S3-compatible
|
||||||
|
- **ducktape** (Rust) - high-performance option
|
||||||
|
|
||||||
|
Configure with environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TURBO_API=https://your-cache-server.com
|
||||||
|
TURBO_TOKEN=your-auth-token
|
||||||
|
TURBO_TEAM=your-team
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cache Behavior Control
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Disable remote cache for a run
|
||||||
|
turbo build --remote-cache-read-only # read but don't write
|
||||||
|
turbo build --no-cache # skip cache entirely
|
||||||
|
|
||||||
|
# Environment variable alternative
|
||||||
|
TURBO_REMOTE_ONLY=true # only use remote, skip local
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debugging Remote Cache
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verbose output shows cache operations
|
||||||
|
turbo build --verbosity=2
|
||||||
|
|
||||||
|
# Check if remote cache is configured
|
||||||
|
turbo config
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for:
|
||||||
|
|
||||||
|
- "Remote caching enabled" in output
|
||||||
|
- Upload/download messages during runs
|
||||||
|
- "cache hit, replaying output" with remote cache indicator
|
||||||
79
.agents/skills/turborepo/references/ci/RULE.md
Normal file
79
.agents/skills/turborepo/references/ci/RULE.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# CI/CD with Turborepo
|
||||||
|
|
||||||
|
General principles for running Turborepo in continuous integration environments.
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
### Always Use `turbo run` in CI
|
||||||
|
|
||||||
|
**Never use the `turbo <tasks>` shorthand in CI or scripts.** Always use `turbo run`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CORRECT - Always use in CI, package.json, scripts
|
||||||
|
turbo run build test lint
|
||||||
|
|
||||||
|
# WRONG - Shorthand is only for one-off terminal commands
|
||||||
|
turbo build test lint
|
||||||
|
```
|
||||||
|
|
||||||
|
The shorthand `turbo <tasks>` is only for one-off invocations typed directly in terminal by humans or agents. Anywhere the command is written into code (CI, package.json, scripts), use `turbo run`.
|
||||||
|
|
||||||
|
### Enable Remote Caching
|
||||||
|
|
||||||
|
Remote caching dramatically speeds up CI by sharing cached artifacts across runs.
|
||||||
|
|
||||||
|
Required environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TURBO_TOKEN=your_vercel_token
|
||||||
|
TURBO_TEAM=your_team_slug
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use --affected for PR Builds
|
||||||
|
|
||||||
|
The `--affected` flag only runs tasks for packages changed since the base branch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run build test --affected
|
||||||
|
```
|
||||||
|
|
||||||
|
This requires Git history to compute what changed.
|
||||||
|
|
||||||
|
## Git History Requirements
|
||||||
|
|
||||||
|
### Fetch Depth
|
||||||
|
|
||||||
|
`--affected` needs access to the merge base. Shallow clones break this.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# GitHub Actions
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2 # Minimum for --affected
|
||||||
|
# Use 0 for full history if merge base is far
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why Shallow Clones Break --affected
|
||||||
|
|
||||||
|
Turborepo compares the current HEAD to the merge base with `main`. If that commit isn't fetched, `--affected` falls back to running everything.
|
||||||
|
|
||||||
|
For PRs with many commits, consider:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
fetch-depth: 0 # Full history
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables Reference
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
| ------------------- | ------------------------------------ |
|
||||||
|
| `TURBO_TOKEN` | Vercel access token for remote cache |
|
||||||
|
| `TURBO_TEAM` | Your Vercel team slug |
|
||||||
|
| `TURBO_REMOTE_ONLY` | Skip local cache, use remote only |
|
||||||
|
| `TURBO_LOG_ORDER` | Set to `grouped` for cleaner CI logs |
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [github-actions.md](./github-actions.md) - GitHub Actions setup
|
||||||
|
- [vercel.md](./vercel.md) - Vercel deployment
|
||||||
|
- [patterns.md](./patterns.md) - CI optimization patterns
|
||||||
162
.agents/skills/turborepo/references/ci/github-actions.md
Normal file
162
.agents/skills/turborepo/references/ci/github-actions.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# GitHub Actions
|
||||||
|
|
||||||
|
Complete setup guide for Turborepo with GitHub Actions.
|
||||||
|
|
||||||
|
## Basic Workflow Structure
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build and Test
|
||||||
|
run: turbo run build test lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Package Manager Setup
|
||||||
|
|
||||||
|
### pnpm
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: "pnpm"
|
||||||
|
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
```
|
||||||
|
|
||||||
|
### Yarn
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: "yarn"
|
||||||
|
|
||||||
|
- run: yarn install --frozen-lockfile
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bun
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- uses: oven-sh/setup-bun@v1
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- run: bun install --frozen-lockfile
|
||||||
|
```
|
||||||
|
|
||||||
|
## Remote Cache Setup
|
||||||
|
|
||||||
|
### 1. Create Vercel Access Token
|
||||||
|
|
||||||
|
1. Go to [Vercel Dashboard](https://vercel.com/account/tokens)
|
||||||
|
2. Create a new token with appropriate scope
|
||||||
|
3. Copy the token value
|
||||||
|
|
||||||
|
### 2. Add Secrets and Variables
|
||||||
|
|
||||||
|
In your GitHub repository settings:
|
||||||
|
|
||||||
|
**Secrets** (Settings > Secrets and variables > Actions > Secrets):
|
||||||
|
|
||||||
|
- `TURBO_TOKEN`: Your Vercel access token
|
||||||
|
|
||||||
|
**Variables** (Settings > Secrets and variables > Actions > Variables):
|
||||||
|
|
||||||
|
- `TURBO_TEAM`: Your Vercel team slug
|
||||||
|
|
||||||
|
### 3. Add to Workflow
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Alternative: actions/cache
|
||||||
|
|
||||||
|
If you can't use remote cache, cache Turborepo's local cache directory:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: .turbo
|
||||||
|
key: turbo-${{ runner.os }}-${{ hashFiles('**/turbo.json', '**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
turbo-${{ runner.os }}-
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: This is less effective than remote cache since it's per-branch.
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: "pnpm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: turbo run build --affected
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: turbo run test --affected
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: turbo run lint --affected
|
||||||
|
```
|
||||||
145
.agents/skills/turborepo/references/ci/patterns.md
Normal file
145
.agents/skills/turborepo/references/ci/patterns.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# CI Optimization Patterns
|
||||||
|
|
||||||
|
Strategies for efficient CI/CD with Turborepo.
|
||||||
|
|
||||||
|
## PR vs Main Branch Builds
|
||||||
|
|
||||||
|
### PR Builds: Only Affected
|
||||||
|
|
||||||
|
Test only what changed in the PR:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Test (PR)
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
run: turbo run build test --affected
|
||||||
|
```
|
||||||
|
|
||||||
|
### Main Branch: Full Build
|
||||||
|
|
||||||
|
Ensure complete validation on merge:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Test (Main)
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
run: turbo run build test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Git Ranges with --filter
|
||||||
|
|
||||||
|
For advanced scenarios, use `--filter` with git refs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Changes since specific commit
|
||||||
|
turbo run test --filter="...[abc123]"
|
||||||
|
|
||||||
|
# Changes between refs
|
||||||
|
turbo run test --filter="...[main...HEAD]"
|
||||||
|
|
||||||
|
# Changes in last 3 commits
|
||||||
|
turbo run test --filter="...[HEAD~3]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Caching Strategies
|
||||||
|
|
||||||
|
### Remote Cache (Recommended)
|
||||||
|
|
||||||
|
Best performance - shared across all CI runs and developers:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
env:
|
||||||
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### actions/cache Fallback
|
||||||
|
|
||||||
|
When remote cache isn't available:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: .turbo
|
||||||
|
key: turbo-${{ runner.os }}-${{ github.sha }}
|
||||||
|
restore-keys: |
|
||||||
|
turbo-${{ runner.os }}-${{ github.ref }}-
|
||||||
|
turbo-${{ runner.os }}-
|
||||||
|
```
|
||||||
|
|
||||||
|
Limitations:
|
||||||
|
|
||||||
|
- Cache is branch-scoped
|
||||||
|
- PRs restore from base branch cache
|
||||||
|
- Less efficient than remote cache
|
||||||
|
|
||||||
|
## Matrix Builds
|
||||||
|
|
||||||
|
Test across Node versions:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node: [18, 20, 22]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node }}
|
||||||
|
|
||||||
|
- run: turbo run test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallelizing Across Jobs
|
||||||
|
|
||||||
|
Split tasks into separate jobs:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: turbo run lint --affected
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: turbo run test --affected
|
||||||
|
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint, test]
|
||||||
|
steps:
|
||||||
|
- run: turbo run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache Considerations
|
||||||
|
|
||||||
|
When parallelizing:
|
||||||
|
|
||||||
|
- Each job has separate cache writes
|
||||||
|
- Remote cache handles this automatically
|
||||||
|
- With actions/cache, use unique keys per job to avoid conflicts
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: .turbo
|
||||||
|
key: turbo-${{ runner.os }}-${{ github.job }}-${{ github.sha }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conditional Tasks
|
||||||
|
|
||||||
|
Skip expensive tasks on draft PRs:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: E2E Tests
|
||||||
|
if: github.event.pull_request.draft == false
|
||||||
|
run: turbo run test:e2e --affected
|
||||||
|
```
|
||||||
|
|
||||||
|
Or require label for full test:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Full Test Suite
|
||||||
|
if: contains(github.event.pull_request.labels.*.name, 'full-test')
|
||||||
|
run: turbo run test
|
||||||
|
```
|
||||||
103
.agents/skills/turborepo/references/ci/vercel.md
Normal file
103
.agents/skills/turborepo/references/ci/vercel.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Vercel Deployment
|
||||||
|
|
||||||
|
Turborepo integrates seamlessly with Vercel for monorepo deployments.
|
||||||
|
|
||||||
|
## Remote Cache
|
||||||
|
|
||||||
|
Remote caching is **automatically enabled** when deploying to Vercel. No configuration needed - Vercel detects Turborepo and enables caching.
|
||||||
|
|
||||||
|
This means:
|
||||||
|
|
||||||
|
- No `TURBO_TOKEN` or `TURBO_TEAM` setup required on Vercel
|
||||||
|
- Cache is shared across all deployments
|
||||||
|
- Preview and production builds benefit from cache
|
||||||
|
|
||||||
|
## turbo-ignore
|
||||||
|
|
||||||
|
Skip unnecessary builds when a package hasn't changed using `turbo-ignore`.
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx turbo-ignore
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install globally in your project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add -D turbo-ignore
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setup in Vercel
|
||||||
|
|
||||||
|
1. Go to your project in Vercel Dashboard
|
||||||
|
2. Navigate to Settings > Git > Ignored Build Step
|
||||||
|
3. Select "Custom" and enter:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx turbo-ignore
|
||||||
|
```
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
`turbo-ignore` checks if the current package (or its dependencies) changed since the last successful deployment:
|
||||||
|
|
||||||
|
1. Compares current commit to last deployed commit
|
||||||
|
2. Uses Turborepo's dependency graph
|
||||||
|
3. Returns exit code 0 (skip) if no changes
|
||||||
|
4. Returns exit code 1 (build) if changes detected
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check specific package
|
||||||
|
npx turbo-ignore web
|
||||||
|
|
||||||
|
# Use specific comparison ref
|
||||||
|
npx turbo-ignore --fallback=HEAD~1
|
||||||
|
|
||||||
|
# Verbose output
|
||||||
|
npx turbo-ignore --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Set environment variables in Vercel Dashboard:
|
||||||
|
|
||||||
|
1. Go to Project Settings > Environment Variables
|
||||||
|
2. Add variables for each environment (Production, Preview, Development)
|
||||||
|
|
||||||
|
Common variables:
|
||||||
|
|
||||||
|
- `DATABASE_URL`
|
||||||
|
- `API_KEY`
|
||||||
|
- Package-specific config
|
||||||
|
|
||||||
|
## Monorepo Root Directory
|
||||||
|
|
||||||
|
For monorepos, set the root directory in Vercel:
|
||||||
|
|
||||||
|
1. Project Settings > General > Root Directory
|
||||||
|
2. Set to the package path (e.g., `apps/web`)
|
||||||
|
|
||||||
|
Vercel automatically:
|
||||||
|
|
||||||
|
- Installs dependencies from monorepo root
|
||||||
|
- Runs build from the package directory
|
||||||
|
- Detects framework settings
|
||||||
|
|
||||||
|
## Build Command
|
||||||
|
|
||||||
|
Vercel auto-detects `turbo run build` when `turbo.json` exists at root.
|
||||||
|
|
||||||
|
Override if needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run build --filter=web
|
||||||
|
```
|
||||||
|
|
||||||
|
Or for production-only optimizations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run build --filter=web --env-mode=strict
|
||||||
|
```
|
||||||
100
.agents/skills/turborepo/references/cli/RULE.md
Normal file
100
.agents/skills/turborepo/references/cli/RULE.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# turbo run
|
||||||
|
|
||||||
|
The primary command for executing tasks across your monorepo.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full form (use in CI, package.json, scripts)
|
||||||
|
turbo run <tasks>
|
||||||
|
|
||||||
|
# Shorthand (only for one-off terminal invocations)
|
||||||
|
turbo <tasks>
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to Use `turbo run` vs `turbo`
|
||||||
|
|
||||||
|
**Always use `turbo run` when the command is written into code:**
|
||||||
|
|
||||||
|
- `package.json` scripts
|
||||||
|
- CI/CD workflows (GitHub Actions, etc.)
|
||||||
|
- Shell scripts
|
||||||
|
- Documentation
|
||||||
|
- Any static/committed configuration
|
||||||
|
|
||||||
|
**Only use `turbo` (shorthand) for:**
|
||||||
|
|
||||||
|
- One-off commands typed directly in terminal
|
||||||
|
- Ad-hoc invocations by humans or agents
|
||||||
|
|
||||||
|
```json
|
||||||
|
// package.json - ALWAYS use "turbo run"
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"build": "turbo run build",
|
||||||
|
"dev": "turbo run dev",
|
||||||
|
"lint": "turbo run lint",
|
||||||
|
"test": "turbo run test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# CI workflow - ALWAYS use "turbo run"
|
||||||
|
- run: turbo run build --affected
|
||||||
|
- run: turbo run test --affected
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal one-off - shorthand OK
|
||||||
|
turbo build --filter=web
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Tasks
|
||||||
|
|
||||||
|
Tasks must be defined in `turbo.json` before running.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Single task
|
||||||
|
turbo build
|
||||||
|
|
||||||
|
# Multiple tasks
|
||||||
|
turbo run build lint test
|
||||||
|
|
||||||
|
# See available tasks (run without arguments)
|
||||||
|
turbo run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Passing Arguments to Scripts
|
||||||
|
|
||||||
|
Use `--` to pass arguments through to the underlying package scripts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run build -- --sourcemap
|
||||||
|
turbo test -- --watch
|
||||||
|
turbo lint -- --fix
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything after `--` goes directly to the task's script.
|
||||||
|
|
||||||
|
## Package Selection
|
||||||
|
|
||||||
|
By default, turbo runs tasks in all packages. Use `--filter` to narrow scope:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo build --filter=web
|
||||||
|
turbo test --filter=./apps/*
|
||||||
|
```
|
||||||
|
|
||||||
|
See `filtering/` for complete filter syntax.
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Goal | Command |
|
||||||
|
| ------------------- | -------------------------- |
|
||||||
|
| Build everything | `turbo build` |
|
||||||
|
| Build one package | `turbo build --filter=web` |
|
||||||
|
| Multiple tasks | `turbo build lint test` |
|
||||||
|
| Pass args to script | `turbo build -- --arg` |
|
||||||
|
| Preview run | `turbo build --dry` |
|
||||||
|
| Force rebuild | `turbo build --force` |
|
||||||
297
.agents/skills/turborepo/references/cli/commands.md
Normal file
297
.agents/skills/turborepo/references/cli/commands.md
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
# turbo run Flags Reference
|
||||||
|
|
||||||
|
Full docs: https://turborepo.dev/docs/reference/run
|
||||||
|
|
||||||
|
## Package Selection
|
||||||
|
|
||||||
|
### `--filter` / `-F`
|
||||||
|
|
||||||
|
Select specific packages to run tasks in.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo build --filter=web
|
||||||
|
turbo build -F=@repo/ui -F=@repo/utils
|
||||||
|
turbo test --filter=./apps/*
|
||||||
|
```
|
||||||
|
|
||||||
|
See `filtering/` for complete syntax (globs, dependencies, git ranges).
|
||||||
|
|
||||||
|
### Task Identifier Syntax (v2.2.4+)
|
||||||
|
|
||||||
|
Run specific package tasks directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run web#build # Build web package
|
||||||
|
turbo run web#build docs#lint # Multiple specific tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
### `--affected`
|
||||||
|
|
||||||
|
Run only in packages changed since the base branch.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo build --affected
|
||||||
|
turbo test --affected --filter=./apps/* # combine with filter
|
||||||
|
```
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
|
||||||
|
- Default: compares `main...HEAD`
|
||||||
|
- In GitHub Actions: auto-detects `GITHUB_BASE_REF`
|
||||||
|
- Override base: `TURBO_SCM_BASE=development turbo build --affected`
|
||||||
|
- Override head: `TURBO_SCM_HEAD=your-branch turbo build --affected`
|
||||||
|
|
||||||
|
**Requires git history** - shallow clones may fall back to running all tasks.
|
||||||
|
|
||||||
|
## Execution Control
|
||||||
|
|
||||||
|
### `--dry` / `--dry=json`
|
||||||
|
|
||||||
|
Preview what would run without executing.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo build --dry # human-readable
|
||||||
|
turbo build --dry=json # machine-readable
|
||||||
|
```
|
||||||
|
|
||||||
|
### `--force`
|
||||||
|
|
||||||
|
Ignore all cached artifacts, re-run everything.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo build --force
|
||||||
|
```
|
||||||
|
|
||||||
|
### `--concurrency`
|
||||||
|
|
||||||
|
Limit parallel task execution.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo build --concurrency=4 # max 4 tasks
|
||||||
|
turbo build --concurrency=50% # 50% of CPU cores
|
||||||
|
```
|
||||||
|
|
||||||
|
### `--continue`
|
||||||
|
|
||||||
|
Keep running other tasks when one fails.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo build test --continue
|
||||||
|
```
|
||||||
|
|
||||||
|
### `--only`
|
||||||
|
|
||||||
|
Run only the specified task, skip its dependencies.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo build --only # skip running dependsOn tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
### `--parallel` (Discouraged)
|
||||||
|
|
||||||
|
Ignores task graph dependencies, runs all tasks simultaneously. **Avoid using this flag**—if tasks need to run in parallel, configure `dependsOn` correctly instead. Using `--parallel` bypasses Turborepo's dependency graph, which can cause race conditions and incorrect builds.
|
||||||
|
|
||||||
|
## Cache Control
|
||||||
|
|
||||||
|
### `--cache`
|
||||||
|
|
||||||
|
Fine-grained cache behavior control.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Default: read/write both local and remote
|
||||||
|
turbo build --cache=local:rw,remote:rw
|
||||||
|
|
||||||
|
# Read-only local, no remote
|
||||||
|
turbo build --cache=local:r,remote:
|
||||||
|
|
||||||
|
# Disable local, read-only remote
|
||||||
|
turbo build --cache=local:,remote:r
|
||||||
|
|
||||||
|
# Disable all caching
|
||||||
|
turbo build --cache=local:,remote:
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output & Debugging
|
||||||
|
|
||||||
|
### `--graph`
|
||||||
|
|
||||||
|
Generate task graph visualization.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo build --graph # opens in browser
|
||||||
|
turbo build --graph=graph.svg # SVG file
|
||||||
|
turbo build --graph=graph.png # PNG file
|
||||||
|
turbo build --graph=graph.json # JSON data
|
||||||
|
turbo build --graph=graph.mermaid # Mermaid diagram
|
||||||
|
```
|
||||||
|
|
||||||
|
### `--summarize`
|
||||||
|
|
||||||
|
Generate JSON run summary for debugging.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo build --summarize
|
||||||
|
# creates .turbo/runs/<run-id>.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### `--output-logs`
|
||||||
|
|
||||||
|
Control log output verbosity.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo build --output-logs=full # all logs (default)
|
||||||
|
turbo build --output-logs=new-only # only cache misses
|
||||||
|
turbo build --output-logs=errors-only # only failures
|
||||||
|
turbo build --output-logs=none # silent
|
||||||
|
```
|
||||||
|
|
||||||
|
### `--profile`
|
||||||
|
|
||||||
|
Generate Chrome tracing profile for performance analysis.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo build --profile=profile.json
|
||||||
|
# open chrome://tracing and load the file
|
||||||
|
```
|
||||||
|
|
||||||
|
### `--verbosity` / `-v`
|
||||||
|
|
||||||
|
Control turbo's own log level.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo build -v # verbose
|
||||||
|
turbo build -vv # more verbose
|
||||||
|
turbo build -vvv # maximum verbosity
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
### `--env-mode`
|
||||||
|
|
||||||
|
Control environment variable handling.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo build --env-mode=strict # only declared env vars (default)
|
||||||
|
turbo build --env-mode=loose # include all env vars in hash
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
### `--ui`
|
||||||
|
|
||||||
|
Select output interface.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo build --ui=tui # interactive terminal UI (default in TTY)
|
||||||
|
turbo build --ui=stream # streaming logs (default in CI)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# turbo-ignore
|
||||||
|
|
||||||
|
Full docs: https://turborepo.dev/docs/reference/turbo-ignore
|
||||||
|
|
||||||
|
Skip CI work when nothing relevant changed. Useful for skipping container setup.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if build is needed for current package (uses Automatic Package Scoping)
|
||||||
|
npx turbo-ignore
|
||||||
|
|
||||||
|
# Check specific package
|
||||||
|
npx turbo-ignore web
|
||||||
|
|
||||||
|
# Check specific task
|
||||||
|
npx turbo-ignore --task=test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exit Codes
|
||||||
|
|
||||||
|
- `0`: No changes detected - skip CI work
|
||||||
|
- `1`: Changes detected - proceed with CI
|
||||||
|
|
||||||
|
## CI Integration Example
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# GitHub Actions
|
||||||
|
- name: Check for changes
|
||||||
|
id: turbo-ignore
|
||||||
|
run: npx turbo-ignore web
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
if: steps.turbo-ignore.outcome == 'failure' # changes detected
|
||||||
|
run: pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comparison Depth
|
||||||
|
|
||||||
|
Default: compares to parent commit (`HEAD^1`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compare to specific commit
|
||||||
|
npx turbo-ignore --fallback=abc123
|
||||||
|
|
||||||
|
# Compare to branch
|
||||||
|
npx turbo-ignore --fallback=main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Other Commands
|
||||||
|
|
||||||
|
## turbo boundaries
|
||||||
|
|
||||||
|
Check workspace violations (experimental).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo boundaries
|
||||||
|
```
|
||||||
|
|
||||||
|
See `references/boundaries/` for configuration.
|
||||||
|
|
||||||
|
## turbo watch
|
||||||
|
|
||||||
|
Re-run tasks on file changes.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo watch build test
|
||||||
|
```
|
||||||
|
|
||||||
|
See `references/watch/` for details.
|
||||||
|
|
||||||
|
## turbo prune
|
||||||
|
|
||||||
|
Create sparse checkout for Docker.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo prune web --docker
|
||||||
|
```
|
||||||
|
|
||||||
|
## turbo link / unlink
|
||||||
|
|
||||||
|
Connect/disconnect Remote Cache.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo link # connect to Vercel Remote Cache
|
||||||
|
turbo unlink # disconnect
|
||||||
|
```
|
||||||
|
|
||||||
|
## turbo login / logout
|
||||||
|
|
||||||
|
Authenticate with Remote Cache provider.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo login # authenticate
|
||||||
|
turbo logout # log out
|
||||||
|
```
|
||||||
|
|
||||||
|
## turbo generate
|
||||||
|
|
||||||
|
Scaffold new packages.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo generate
|
||||||
|
```
|
||||||
211
.agents/skills/turborepo/references/configuration/RULE.md
Normal file
211
.agents/skills/turborepo/references/configuration/RULE.md
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
# turbo.json Configuration Overview
|
||||||
|
|
||||||
|
Configuration reference for Turborepo. Full docs: https://turborepo.dev/docs/reference/configuration
|
||||||
|
|
||||||
|
## File Location
|
||||||
|
|
||||||
|
Root `turbo.json` lives at repo root, sibling to root `package.json`:
|
||||||
|
|
||||||
|
```
|
||||||
|
my-monorepo/
|
||||||
|
├── turbo.json # Root configuration
|
||||||
|
├── package.json
|
||||||
|
└── packages/
|
||||||
|
└── web/
|
||||||
|
├── turbo.json # Package Configuration (optional)
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Always Prefer Package Tasks Over Root Tasks
|
||||||
|
|
||||||
|
**Always use package tasks. Only use Root Tasks if you cannot succeed with package tasks.**
|
||||||
|
|
||||||
|
Package tasks enable parallelization, individual caching, and filtering. Define scripts in each package's `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// packages/web/package.json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"build": "next build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"test": "vitest",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// packages/api/package.json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"test": "vitest",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Root package.json - delegates to turbo
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"build": "turbo run build",
|
||||||
|
"lint": "turbo run lint",
|
||||||
|
"test": "turbo run test",
|
||||||
|
"typecheck": "turbo run typecheck"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When you run `turbo run lint`, Turborepo finds all packages with a `lint` script and runs them **in parallel**.
|
||||||
|
|
||||||
|
**Root Tasks are a fallback**, not the default. Only use them for tasks that truly cannot run per-package (e.g., repo-level CI scripts, workspace-wide config generation).
|
||||||
|
|
||||||
|
```json
|
||||||
|
// AVOID: Task logic in root defeats parallelization
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"lint": "eslint apps/web && eslint apps/api && eslint packages/ui"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Basic Structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://v2-8-17-canary-13.turborepo.dev/schema.json",
|
||||||
|
"globalEnv": ["CI"],
|
||||||
|
"globalDependencies": ["tsconfig.json"],
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": ["dist/**"]
|
||||||
|
},
|
||||||
|
"dev": {
|
||||||
|
"cache": false,
|
||||||
|
"persistent": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `$schema` key enables IDE autocompletion and validation.
|
||||||
|
|
||||||
|
## Configuration Sections
|
||||||
|
|
||||||
|
**Global options** - Settings affecting all tasks:
|
||||||
|
|
||||||
|
- `globalEnv`, `globalDependencies`, `globalPassThroughEnv`
|
||||||
|
- `cacheDir`, `daemon`, `envMode`, `ui`, `remoteCache`
|
||||||
|
|
||||||
|
**Task definitions** - Per-task settings in `tasks` object:
|
||||||
|
|
||||||
|
- `dependsOn`, `outputs`, `inputs`, `env`
|
||||||
|
- `cache`, `persistent`, `interactive`, `outputLogs`
|
||||||
|
|
||||||
|
## Package Configurations
|
||||||
|
|
||||||
|
Use `turbo.json` in individual packages to override root settings:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// packages/web/turbo.json
|
||||||
|
{
|
||||||
|
"extends": ["//"],
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"outputs": [".next/**", "!.next/cache/**"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `"extends": ["//"]` is required - it references the root configuration.
|
||||||
|
|
||||||
|
**When to use Package Configurations:**
|
||||||
|
|
||||||
|
- Framework-specific outputs (Next.js, Vite, etc.)
|
||||||
|
- Package-specific env vars
|
||||||
|
- Different caching rules for specific packages
|
||||||
|
- Keeping framework config close to the framework code
|
||||||
|
|
||||||
|
### Extending from Other Packages
|
||||||
|
|
||||||
|
You can extend from config packages instead of just root:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// packages/web/turbo.json
|
||||||
|
{
|
||||||
|
"extends": ["//", "@repo/turbo-config"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding to Inherited Arrays with `$TURBO_EXTENDS$`
|
||||||
|
|
||||||
|
By default, array fields in Package Configurations **replace** root values. Use `$TURBO_EXTENDS$` to **append** instead:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Root turbo.json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"outputs": ["dist/**"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
// packages/web/turbo.json
|
||||||
|
{
|
||||||
|
"extends": ["//"],
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
// Inherits "dist/**" from root, adds ".next/**"
|
||||||
|
"outputs": ["$TURBO_EXTENDS$", ".next/**", "!.next/cache/**"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Without `$TURBO_EXTENDS$`, outputs would only be `[".next/**", "!.next/cache/**"]`.
|
||||||
|
|
||||||
|
**Works with:**
|
||||||
|
|
||||||
|
- `dependsOn`
|
||||||
|
- `env`
|
||||||
|
- `inputs`
|
||||||
|
- `outputs`
|
||||||
|
- `passThroughEnv`
|
||||||
|
- `with`
|
||||||
|
|
||||||
|
### Excluding Tasks from Packages
|
||||||
|
|
||||||
|
Use `extends: false` to exclude a task from a package:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// packages/ui/turbo.json
|
||||||
|
{
|
||||||
|
"extends": ["//"],
|
||||||
|
"tasks": {
|
||||||
|
"e2e": {
|
||||||
|
"extends": false // UI package doesn't have e2e tests
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## `turbo.jsonc` for Comments
|
||||||
|
|
||||||
|
Use `turbo.jsonc` extension to add comments with IDE support:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// turbo.jsonc
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
// Next.js outputs
|
||||||
|
"outputs": [".next/**", "!.next/cache/**"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# Global Options Reference
|
||||||
|
|
||||||
|
Options that affect all tasks. Full docs: https://turborepo.dev/docs/reference/configuration
|
||||||
|
|
||||||
|
## globalEnv
|
||||||
|
|
||||||
|
Environment variables affecting all task hashes.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"globalEnv": ["CI", "NODE_ENV", "VERCEL_*"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use for variables that should invalidate all caches when changed.
|
||||||
|
|
||||||
|
## globalDependencies
|
||||||
|
|
||||||
|
Files that affect all task hashes.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"globalDependencies": ["tsconfig.json", ".env", "pnpm-lock.yaml"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Lockfile is included by default. Add shared configs here.
|
||||||
|
|
||||||
|
## globalPassThroughEnv
|
||||||
|
|
||||||
|
Variables available to tasks but not included in hash.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"globalPassThroughEnv": ["AWS_SECRET_KEY", "GITHUB_TOKEN"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use for credentials that shouldn't affect cache keys.
|
||||||
|
|
||||||
|
## cacheDir
|
||||||
|
|
||||||
|
Custom cache location. Default: `node_modules/.cache/turbo`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cacheDir": ".turbo/cache"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## daemon
|
||||||
|
|
||||||
|
**Deprecated**: The daemon is no longer used for `turbo run` and this option will be removed in version 3.0. The daemon is still used by `turbo watch` and the Turborepo LSP.
|
||||||
|
|
||||||
|
## envMode
|
||||||
|
|
||||||
|
How unspecified env vars are handled. Default: `"strict"`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"envMode": "strict" // Only specified vars available
|
||||||
|
// or
|
||||||
|
"envMode": "loose" // All vars pass through
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Strict mode catches missing env declarations.
|
||||||
|
|
||||||
|
## ui
|
||||||
|
|
||||||
|
Terminal UI mode. Default: `"stream"`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ui": "tui" // Interactive terminal UI
|
||||||
|
// or
|
||||||
|
"ui": "stream" // Traditional streaming logs
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
TUI provides better UX for parallel tasks.
|
||||||
|
|
||||||
|
## remoteCache
|
||||||
|
|
||||||
|
Configure remote caching.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"remoteCache": {
|
||||||
|
"enabled": true,
|
||||||
|
"signature": true,
|
||||||
|
"timeout": 30,
|
||||||
|
"uploadTimeout": 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
| --------------- | ---------------------- | ------------------------------------------------------ |
|
||||||
|
| `enabled` | `true` | Enable/disable remote caching |
|
||||||
|
| `signature` | `false` | Sign artifacts with `TURBO_REMOTE_CACHE_SIGNATURE_KEY` |
|
||||||
|
| `preflight` | `false` | Send OPTIONS request before cache requests |
|
||||||
|
| `timeout` | `30` | Timeout in seconds for cache operations |
|
||||||
|
| `uploadTimeout` | `60` | Timeout in seconds for uploads |
|
||||||
|
| `apiUrl` | `"https://vercel.com"` | Remote cache API endpoint |
|
||||||
|
| `loginUrl` | `"https://vercel.com"` | Login endpoint |
|
||||||
|
| `teamId` | - | Team ID (must start with `team_`) |
|
||||||
|
| `teamSlug` | - | Team slug for querystring |
|
||||||
|
|
||||||
|
See https://turborepo.dev/docs/core-concepts/remote-caching for setup.
|
||||||
|
|
||||||
|
## concurrency
|
||||||
|
|
||||||
|
Default: `"10"`
|
||||||
|
|
||||||
|
Limit parallel task execution.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"concurrency": "4" // Max 4 tasks at once
|
||||||
|
// or
|
||||||
|
"concurrency": "50%" // 50% of available CPUs
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## futureFlags
|
||||||
|
|
||||||
|
Enable experimental features that will become default in future versions.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"futureFlags": {
|
||||||
|
"errorsOnlyShowHash": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `errorsOnlyShowHash`
|
||||||
|
|
||||||
|
When using `outputLogs: "errors-only"`, show task hashes on start/completion:
|
||||||
|
|
||||||
|
- Cache miss: `cache miss, executing <hash> (only logging errors)`
|
||||||
|
- Cache hit: `cache hit, replaying logs (no errors) <hash>`
|
||||||
|
|
||||||
|
### `longerSignatureKey`
|
||||||
|
|
||||||
|
Enforce a minimum key length of 32 bytes for `TURBO_REMOTE_CACHE_SIGNATURE_KEY` when `remoteCache.signature` is enabled. Short keys weaken HMAC-SHA256 signatures. Fails the run immediately if the key is too short.
|
||||||
|
|
||||||
|
## noUpdateNotifier
|
||||||
|
|
||||||
|
Disable update notifications when new turbo versions are available.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"noUpdateNotifier": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## dangerouslyDisablePackageManagerCheck
|
||||||
|
|
||||||
|
Bypass the `packageManager` field requirement. Use for incremental migration.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dangerouslyDisablePackageManagerCheck": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Warning**: Unstable lockfiles can cause unpredictable behavior.
|
||||||
|
|
||||||
|
## Git Worktree Cache Sharing
|
||||||
|
|
||||||
|
When working in Git worktrees, Turborepo automatically shares local cache between the main worktree and linked worktrees.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
|
||||||
|
- Detects worktree configuration
|
||||||
|
- Redirects cache to main worktree's `.turbo/cache`
|
||||||
|
- Works alongside Remote Cache
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
|
||||||
|
- Cache hits across branches
|
||||||
|
- Reduced disk usage
|
||||||
|
- Faster branch switching
|
||||||
|
|
||||||
|
**Disabled by**: Setting explicit `cacheDir` in turbo.json.
|
||||||
348
.agents/skills/turborepo/references/configuration/gotchas.md
Normal file
348
.agents/skills/turborepo/references/configuration/gotchas.md
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
# Configuration Gotchas
|
||||||
|
|
||||||
|
Common mistakes and how to fix them.
|
||||||
|
|
||||||
|
## #1 Root Scripts Not Using `turbo run`
|
||||||
|
|
||||||
|
Root `package.json` scripts for turbo tasks MUST use `turbo run`, not direct commands.
|
||||||
|
|
||||||
|
```json
|
||||||
|
// WRONG - bypasses turbo, no parallelization or caching
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"build": "bun build",
|
||||||
|
"dev": "bun dev"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECT - delegates to turbo
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"build": "turbo run build",
|
||||||
|
"dev": "turbo run dev"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this matters:** Running `bun build` or `npm run build` at root bypasses Turborepo entirely - no parallelization, no caching, no dependency graph awareness.
|
||||||
|
|
||||||
|
## #2 Using `&&` to Chain Turbo Tasks
|
||||||
|
|
||||||
|
Don't use `&&` to chain tasks that turbo should orchestrate.
|
||||||
|
|
||||||
|
```json
|
||||||
|
// WRONG - changeset:publish chains turbo task with non-turbo command
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"changeset:publish": "bun build && changeset publish"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECT - use turbo run, let turbo handle dependencies
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"changeset:publish": "turbo run build && changeset publish"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the second command (`changeset publish`) depends on build outputs, the turbo task should run through turbo to get caching and parallelization benefits.
|
||||||
|
|
||||||
|
## #3 Overly Broad globalDependencies
|
||||||
|
|
||||||
|
`globalDependencies` affects hash for ALL tasks in ALL packages. Be specific.
|
||||||
|
|
||||||
|
```json
|
||||||
|
// WRONG - affects all hashes
|
||||||
|
{
|
||||||
|
"globalDependencies": ["**/.env.*local"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECT - move to specific tasks that need it
|
||||||
|
{
|
||||||
|
"globalDependencies": [".env"],
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
||||||
|
"outputs": ["dist/**"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this matters:** `**/.env.*local` matches .env files in ALL packages, causing unnecessary cache invalidation. Instead:
|
||||||
|
|
||||||
|
- Use `globalDependencies` only for truly global files (root `.env`)
|
||||||
|
- Use task-level `inputs` for package-specific .env files with `$TURBO_DEFAULT$` to preserve default behavior
|
||||||
|
|
||||||
|
## #4 Repetitive Task Configuration
|
||||||
|
|
||||||
|
Look for repeated configuration across tasks that can be collapsed.
|
||||||
|
|
||||||
|
```json
|
||||||
|
// WRONG - repetitive env and inputs across tasks
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"env": ["API_URL", "DATABASE_URL"],
|
||||||
|
"inputs": ["$TURBO_DEFAULT$", ".env*"]
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"env": ["API_URL", "DATABASE_URL"],
|
||||||
|
"inputs": ["$TURBO_DEFAULT$", ".env*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BETTER - use globalEnv and globalDependencies
|
||||||
|
{
|
||||||
|
"globalEnv": ["API_URL", "DATABASE_URL"],
|
||||||
|
"globalDependencies": [".env*"],
|
||||||
|
"tasks": {
|
||||||
|
"build": {},
|
||||||
|
"test": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use global vs task-level:**
|
||||||
|
|
||||||
|
- `globalEnv` / `globalDependencies` - affects ALL tasks, use for truly shared config
|
||||||
|
- Task-level `env` / `inputs` - use when only specific tasks need it
|
||||||
|
|
||||||
|
## #5 Using `../` to Traverse Out of Package in `inputs`
|
||||||
|
|
||||||
|
Don't use relative paths like `../` to reference files outside the package. Use `$TURBO_ROOT$` instead.
|
||||||
|
|
||||||
|
```json
|
||||||
|
// WRONG - traversing out of package
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"inputs": ["$TURBO_DEFAULT$", "../shared-config.json"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECT - use $TURBO_ROOT$ for repo root
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/shared-config.json"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## #6 MOST COMMON MISTAKE: Creating Root Tasks
|
||||||
|
|
||||||
|
**DO NOT create Root Tasks. ALWAYS create package tasks.**
|
||||||
|
|
||||||
|
When you need to create a task (build, lint, test, typecheck, etc.):
|
||||||
|
|
||||||
|
1. Add the script to **each relevant package's** `package.json`
|
||||||
|
2. Register the task in root `turbo.json`
|
||||||
|
3. Root `package.json` only contains `turbo run <task>`
|
||||||
|
|
||||||
|
```json
|
||||||
|
// WRONG - DO NOT DO THIS
|
||||||
|
// Root package.json with task logic
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"build": "cd apps/web && next build && cd ../api && tsc",
|
||||||
|
"lint": "eslint apps/ packages/",
|
||||||
|
"test": "vitest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECT - DO THIS
|
||||||
|
// apps/web/package.json
|
||||||
|
{ "scripts": { "build": "next build", "lint": "eslint .", "test": "vitest" } }
|
||||||
|
|
||||||
|
// apps/api/package.json
|
||||||
|
{ "scripts": { "build": "tsc", "lint": "eslint .", "test": "vitest" } }
|
||||||
|
|
||||||
|
// packages/ui/package.json
|
||||||
|
{ "scripts": { "build": "tsc", "lint": "eslint .", "test": "vitest" } }
|
||||||
|
|
||||||
|
// Root package.json - ONLY delegates
|
||||||
|
{ "scripts": { "build": "turbo run build", "lint": "turbo run lint", "test": "turbo run test" } }
|
||||||
|
|
||||||
|
// turbo.json - register tasks
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
|
||||||
|
"lint": {},
|
||||||
|
"test": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this matters:**
|
||||||
|
|
||||||
|
- Package tasks run in **parallel** across all packages
|
||||||
|
- Each package's output is cached **individually**
|
||||||
|
- You can **filter** to specific packages: `turbo run test --filter=web`
|
||||||
|
|
||||||
|
Root Tasks (`//#taskname`) defeat all these benefits. Only use them for tasks that truly cannot exist in any package (extremely rare).
|
||||||
|
|
||||||
|
## #7 Tasks That Need Parallel Execution + Cache Invalidation
|
||||||
|
|
||||||
|
Some tasks can run in parallel (don't need built output from dependencies) but must still invalidate cache when dependency source code changes. Using `dependsOn: ["^taskname"]` forces sequential execution. Using no dependencies breaks cache invalidation.
|
||||||
|
|
||||||
|
**Use Transit Nodes for these tasks:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
// WRONG - forces sequential execution (SLOW)
|
||||||
|
"my-task": {
|
||||||
|
"dependsOn": ["^my-task"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ALSO WRONG - no dependency awareness (INCORRECT CACHING)
|
||||||
|
"my-task": {}
|
||||||
|
|
||||||
|
// CORRECT - use Transit Nodes for parallel + correct caching
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"transit": { "dependsOn": ["^transit"] },
|
||||||
|
"my-task": { "dependsOn": ["transit"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why Transit Nodes work:**
|
||||||
|
|
||||||
|
- `transit` creates dependency relationships without matching any actual script
|
||||||
|
- Tasks that depend on `transit` gain dependency awareness
|
||||||
|
- Since `transit` completes instantly (no script), tasks run in parallel
|
||||||
|
- Cache correctly invalidates when dependency source code changes
|
||||||
|
|
||||||
|
**How to identify tasks that need this pattern:** Look for tasks that read source files from dependencies but don't need their build outputs.
|
||||||
|
|
||||||
|
## Missing outputs for File-Producing Tasks
|
||||||
|
|
||||||
|
**Before flagging missing `outputs`, check what the task actually produces:**
|
||||||
|
|
||||||
|
1. Read the package's script (e.g., `"build": "tsc"`, `"test": "vitest"`)
|
||||||
|
2. Determine if it writes files to disk or only outputs to stdout
|
||||||
|
3. Only flag if the task produces files that should be cached
|
||||||
|
|
||||||
|
```json
|
||||||
|
// WRONG - build produces files but they're not cached
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECT - outputs are cached
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": ["dist/**"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No `outputs` key is fine for stdout-only tasks. For file-producing tasks, missing `outputs` means Turbo has nothing to cache.
|
||||||
|
|
||||||
|
## Forgetting ^ in dependsOn
|
||||||
|
|
||||||
|
```json
|
||||||
|
// WRONG - looks for "build" in SAME package (infinite loop or missing)
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["build"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECT - runs dependencies' build first
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `^` means "in dependency packages", not "in this package".
|
||||||
|
|
||||||
|
## Missing persistent on Dev Tasks
|
||||||
|
|
||||||
|
```json
|
||||||
|
// WRONG - dependent tasks hang waiting for dev to "finish"
|
||||||
|
"dev": {
|
||||||
|
"cache": false
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECT
|
||||||
|
"dev": {
|
||||||
|
"cache": false,
|
||||||
|
"persistent": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Package Config Missing extends
|
||||||
|
|
||||||
|
```json
|
||||||
|
// WRONG - packages/web/turbo.json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": { "outputs": [".next/**"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECT
|
||||||
|
{
|
||||||
|
"extends": ["//"],
|
||||||
|
"tasks": {
|
||||||
|
"build": { "outputs": [".next/**"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Without `"extends": ["//"]`, Package Configurations are invalid.
|
||||||
|
|
||||||
|
## Root Tasks Need Special Syntax
|
||||||
|
|
||||||
|
To run a task defined only in root `package.json`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# WRONG
|
||||||
|
turbo run format
|
||||||
|
|
||||||
|
# CORRECT
|
||||||
|
turbo run //#format
|
||||||
|
```
|
||||||
|
|
||||||
|
And in dependsOn:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["//#codegen"] // Root package's codegen
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Overwriting Default Inputs
|
||||||
|
|
||||||
|
```json
|
||||||
|
// WRONG - only watches test files, ignores source changes
|
||||||
|
"test": {
|
||||||
|
"inputs": ["tests/**"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECT - extends defaults, adds test files
|
||||||
|
"test": {
|
||||||
|
"inputs": ["$TURBO_DEFAULT$", "tests/**"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Without `$TURBO_DEFAULT$`, you replace all default file watching.
|
||||||
|
|
||||||
|
## Caching Tasks with Side Effects
|
||||||
|
|
||||||
|
```json
|
||||||
|
// WRONG - deploy might be skipped on cache hit
|
||||||
|
"deploy": {
|
||||||
|
"dependsOn": ["build"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECT
|
||||||
|
"deploy": {
|
||||||
|
"dependsOn": ["build"],
|
||||||
|
"cache": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Always disable cache for deploy, publish, or mutation tasks.
|
||||||
281
.agents/skills/turborepo/references/configuration/tasks.md
Normal file
281
.agents/skills/turborepo/references/configuration/tasks.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
# Task Configuration Reference
|
||||||
|
|
||||||
|
Full docs: https://turborepo.dev/docs/reference/configuration#tasks
|
||||||
|
|
||||||
|
## dependsOn
|
||||||
|
|
||||||
|
Controls task execution order.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"dependsOn": [
|
||||||
|
"^build", // Dependencies' build tasks first
|
||||||
|
"codegen", // Same package's codegen task first
|
||||||
|
"shared#build" // Specific package's build task
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Syntax | Meaning |
|
||||||
|
| ---------- | ------------------------------------ |
|
||||||
|
| `^task` | Run `task` in all dependencies first |
|
||||||
|
| `task` | Run `task` in same package first |
|
||||||
|
| `pkg#task` | Run specific package's task first |
|
||||||
|
|
||||||
|
The `^` prefix is crucial - without it, you're referencing the same package.
|
||||||
|
|
||||||
|
### Transit Nodes for Parallel Tasks
|
||||||
|
|
||||||
|
For tasks like `lint` and `check-types` that can run in parallel but need dependency-aware caching:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"transit": { "dependsOn": ["^transit"] },
|
||||||
|
"lint": { "dependsOn": ["transit"] },
|
||||||
|
"check-types": { "dependsOn": ["transit"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**DO NOT use `dependsOn: ["^lint"]`** - this forces sequential execution.
|
||||||
|
**DO NOT use `dependsOn: []`** - this breaks cache invalidation.
|
||||||
|
|
||||||
|
The `transit` task creates dependency relationships without running anything (no matching script), so tasks run in parallel with correct caching.
|
||||||
|
|
||||||
|
## outputs
|
||||||
|
|
||||||
|
Glob patterns for files to cache. **If omitted, nothing is cached.**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"outputs": ["dist/**", "build/**"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Framework examples:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Next.js
|
||||||
|
"outputs": [".next/**", "!.next/cache/**"]
|
||||||
|
|
||||||
|
// Vite
|
||||||
|
"outputs": ["dist/**"]
|
||||||
|
|
||||||
|
// TypeScript (tsc)
|
||||||
|
"outputs": ["dist/**", "*.tsbuildinfo"]
|
||||||
|
|
||||||
|
// No file outputs (lint, typecheck)
|
||||||
|
"outputs": []
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `!` prefix to exclude patterns from caching.
|
||||||
|
|
||||||
|
## inputs
|
||||||
|
|
||||||
|
Files considered when calculating task hash. Defaults to all tracked files in package.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"test": {
|
||||||
|
"inputs": ["src/**", "tests/**", "vitest.config.ts"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Special values:**
|
||||||
|
|
||||||
|
| Value | Meaning |
|
||||||
|
| --------------------- | --------------------------------------- |
|
||||||
|
| `$TURBO_DEFAULT$` | Include default inputs, then add/remove |
|
||||||
|
| `$TURBO_ROOT$/<path>` | Reference files from repo root |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"inputs": ["$TURBO_DEFAULT$", "!README.md", "$TURBO_ROOT$/tsconfig.base.json"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## env
|
||||||
|
|
||||||
|
Environment variables to include in task hash.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"env": [
|
||||||
|
"API_URL",
|
||||||
|
"NEXT_PUBLIC_*", // Wildcard matching
|
||||||
|
"!DEBUG" // Exclude from hash
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Variables listed here affect cache hits - changing the value invalidates cache.
|
||||||
|
|
||||||
|
## cache
|
||||||
|
|
||||||
|
Enable/disable caching for a task. Default: `true`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"dev": { "cache": false },
|
||||||
|
"deploy": { "cache": false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Disable for: dev servers, deploy commands, tasks with side effects.
|
||||||
|
|
||||||
|
## persistent
|
||||||
|
|
||||||
|
Mark long-running tasks that don't exit. Default: `false`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"dev": {
|
||||||
|
"cache": false,
|
||||||
|
"persistent": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Required for dev servers - without it, dependent tasks wait forever.
|
||||||
|
|
||||||
|
## interactive
|
||||||
|
|
||||||
|
Allow task to receive stdin input. Default: `false`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"login": {
|
||||||
|
"cache": false,
|
||||||
|
"interactive": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## outputLogs
|
||||||
|
|
||||||
|
Control when logs are shown. Options: `full`, `hash-only`, `new-only`, `errors-only`, `none`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"outputLogs": "new-only" // Only show logs on cache miss
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## with
|
||||||
|
|
||||||
|
Run tasks alongside this task. For long-running tasks that need runtime dependencies.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"dev": {
|
||||||
|
"with": ["api#dev"],
|
||||||
|
"persistent": true,
|
||||||
|
"cache": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Unlike `dependsOn`, `with` runs tasks concurrently (not sequentially). Use for dev servers that need other services running.
|
||||||
|
|
||||||
|
## interruptible
|
||||||
|
|
||||||
|
Allow `turbo watch` to restart the task on changes. Default: `false`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"dev": {
|
||||||
|
"persistent": true,
|
||||||
|
"interruptible": true,
|
||||||
|
"cache": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use for dev servers that don't automatically detect dependency changes.
|
||||||
|
|
||||||
|
## description
|
||||||
|
|
||||||
|
Human-readable description of the task.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"description": "Compiles the application for production deployment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For documentation only - doesn't affect execution or caching.
|
||||||
|
|
||||||
|
## passThroughEnv
|
||||||
|
|
||||||
|
Environment variables available at runtime but NOT included in cache hash.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"passThroughEnv": ["AWS_SECRET_KEY", "GITHUB_TOKEN"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Warning**: Changes to these vars won't cause cache misses. Use `env` if changes should invalidate cache.
|
||||||
|
|
||||||
|
## extends (Package Configuration only)
|
||||||
|
|
||||||
|
Control task inheritance in Package Configurations.
|
||||||
|
|
||||||
|
```json
|
||||||
|
// packages/ui/turbo.json
|
||||||
|
{
|
||||||
|
"extends": ["//"],
|
||||||
|
"tasks": {
|
||||||
|
"lint": {
|
||||||
|
"extends": false // Exclude from this package
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Value | Behavior |
|
||||||
|
| ---------------- | -------------------------------------------------------------- |
|
||||||
|
| `true` (default) | Inherit from root turbo.json |
|
||||||
|
| `false` | Exclude task from package, or define fresh without inheritance |
|
||||||
96
.agents/skills/turborepo/references/environment/RULE.md
Normal file
96
.agents/skills/turborepo/references/environment/RULE.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# Environment Variables in Turborepo
|
||||||
|
|
||||||
|
Turborepo provides fine-grained control over which environment variables affect task hashing and runtime availability.
|
||||||
|
|
||||||
|
## Configuration Keys
|
||||||
|
|
||||||
|
### `env` - Task-Specific Variables
|
||||||
|
|
||||||
|
Variables that affect a specific task's hash. When these change, only that task rebuilds.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"env": ["DATABASE_URL", "API_KEY"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `globalEnv` - Variables Affecting All Tasks
|
||||||
|
|
||||||
|
Variables that affect EVERY task's hash. When these change, all tasks rebuild.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"globalEnv": ["CI", "NODE_ENV"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `passThroughEnv` - Runtime-Only Variables (Not Hashed)
|
||||||
|
|
||||||
|
Variables available at runtime but NOT included in hash. **Use with caution** - changes won't trigger rebuilds.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"deploy": {
|
||||||
|
"passThroughEnv": ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `globalPassThroughEnv` - Global Runtime Variables
|
||||||
|
|
||||||
|
Same as `passThroughEnv` but for all tasks.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"globalPassThroughEnv": ["GITHUB_TOKEN"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wildcards and Negation
|
||||||
|
|
||||||
|
### Wildcards
|
||||||
|
|
||||||
|
Match multiple variables with `*`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"env": ["MY_API_*", "FEATURE_FLAG_*"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This matches `MY_API_URL`, `MY_API_KEY`, `FEATURE_FLAG_DARK_MODE`, etc.
|
||||||
|
|
||||||
|
### Negation
|
||||||
|
|
||||||
|
Exclude variables (useful with framework inference):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"env": ["!NEXT_PUBLIC_ANALYTICS_ID"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://v2-8-17-canary-13.turborepo.dev/schema.json",
|
||||||
|
"globalEnv": ["CI", "NODE_ENV"],
|
||||||
|
"globalPassThroughEnv": ["GITHUB_TOKEN", "NPM_TOKEN"],
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"env": ["DATABASE_URL", "API_*"],
|
||||||
|
"passThroughEnv": ["SENTRY_AUTH_TOKEN"]
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"env": ["TEST_DATABASE_URL"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
141
.agents/skills/turborepo/references/environment/gotchas.md
Normal file
141
.agents/skills/turborepo/references/environment/gotchas.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# Environment Variable Gotchas
|
||||||
|
|
||||||
|
Common mistakes and how to fix them.
|
||||||
|
|
||||||
|
## .env Files Must Be in `inputs`
|
||||||
|
|
||||||
|
Turbo does NOT read `.env` files. Your framework (Next.js, Vite, etc.) or `dotenv` loads them. But Turbo needs to know when they change.
|
||||||
|
|
||||||
|
**Wrong:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"env": ["DATABASE_URL"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Right:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"env": ["DATABASE_URL"],
|
||||||
|
"inputs": ["$TURBO_DEFAULT$", ".env", ".env.local", ".env.production"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Strict Mode Filters CI Variables
|
||||||
|
|
||||||
|
In strict mode, CI provider variables (GITHUB_TOKEN, GITLAB_CI, etc.) are filtered unless explicitly listed.
|
||||||
|
|
||||||
|
**Symptom:** Task fails with "authentication required" or "permission denied" in CI.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"globalPassThroughEnv": ["GITHUB_TOKEN", "GITLAB_CI", "CI"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## passThroughEnv Doesn't Affect Hash
|
||||||
|
|
||||||
|
Variables in `passThroughEnv` are available at runtime but changes WON'T trigger rebuilds.
|
||||||
|
|
||||||
|
**Dangerous example:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"passThroughEnv": ["API_URL"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `API_URL` changes from staging to production, Turbo may serve a cached build pointing to the wrong API.
|
||||||
|
|
||||||
|
**Use passThroughEnv only for:**
|
||||||
|
|
||||||
|
- Auth tokens that don't affect output (SENTRY_AUTH_TOKEN)
|
||||||
|
- CI metadata (GITHUB_RUN_ID)
|
||||||
|
- Variables consumed after build (deploy credentials)
|
||||||
|
|
||||||
|
## Runtime-Created Variables Are Invisible
|
||||||
|
|
||||||
|
Turbo captures env vars at startup. Variables created during execution aren't seen.
|
||||||
|
|
||||||
|
**Won't work:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In package.json scripts
|
||||||
|
"build": "export API_URL=$COMPUTED_VALUE && next build"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution:** Set vars before invoking turbo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
API_URL=$COMPUTED_VALUE turbo run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Different .env Files for Different Environments
|
||||||
|
|
||||||
|
If you use `.env.development` and `.env.production`, both should be in inputs.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"inputs": [
|
||||||
|
"$TURBO_DEFAULT$",
|
||||||
|
".env",
|
||||||
|
".env.local",
|
||||||
|
".env.development",
|
||||||
|
".env.development.local",
|
||||||
|
".env.production",
|
||||||
|
".env.production.local"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Next.js Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://v2-8-17-canary-13.turborepo.dev/schema.json",
|
||||||
|
"globalEnv": ["CI", "NODE_ENV", "VERCEL"],
|
||||||
|
"globalPassThroughEnv": ["GITHUB_TOKEN", "VERCEL_URL"],
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"env": ["DATABASE_URL", "NEXT_PUBLIC_*", "!NEXT_PUBLIC_ANALYTICS_ID"],
|
||||||
|
"passThroughEnv": ["SENTRY_AUTH_TOKEN"],
|
||||||
|
"inputs": [
|
||||||
|
"$TURBO_DEFAULT$",
|
||||||
|
".env",
|
||||||
|
".env.local",
|
||||||
|
".env.production",
|
||||||
|
".env.production.local"
|
||||||
|
],
|
||||||
|
"outputs": [".next/**", "!.next/cache/**"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This config:
|
||||||
|
|
||||||
|
- Hashes DATABASE*URL and NEXT_PUBLIC*\* vars (except analytics)
|
||||||
|
- Passes through SENTRY_AUTH_TOKEN without hashing
|
||||||
|
- Includes all .env file variants in the hash
|
||||||
|
- Makes CI tokens available globally
|
||||||
101
.agents/skills/turborepo/references/environment/modes.md
Normal file
101
.agents/skills/turborepo/references/environment/modes.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Environment Modes
|
||||||
|
|
||||||
|
Turborepo supports different modes for handling environment variables during task execution.
|
||||||
|
|
||||||
|
## Strict Mode (Default)
|
||||||
|
|
||||||
|
Only explicitly configured variables are available to tasks.
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
|
||||||
|
- Tasks only see vars listed in `env`, `globalEnv`, `passThroughEnv`, or `globalPassThroughEnv`
|
||||||
|
- Unlisted vars are filtered out
|
||||||
|
- Tasks fail if they require unlisted variables
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
|
||||||
|
- Guarantees cache correctness
|
||||||
|
- Prevents accidental dependencies on system vars
|
||||||
|
- Reproducible builds across machines
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Explicit (though it's the default)
|
||||||
|
turbo run build --env-mode=strict
|
||||||
|
```
|
||||||
|
|
||||||
|
## Loose Mode
|
||||||
|
|
||||||
|
All system environment variables are available to tasks.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run build --env-mode=loose
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
|
||||||
|
- Every system env var is passed through
|
||||||
|
- Only vars in `env`/`globalEnv` affect the hash
|
||||||
|
- Other vars are available but NOT hashed
|
||||||
|
|
||||||
|
**Risks:**
|
||||||
|
|
||||||
|
- Cache may restore incorrect results if unhashed vars changed
|
||||||
|
- "Works on my machine" bugs
|
||||||
|
- CI vs local environment mismatches
|
||||||
|
|
||||||
|
**Use case:** Migrating legacy projects or debugging strict mode issues.
|
||||||
|
|
||||||
|
## Framework Inference (Automatic)
|
||||||
|
|
||||||
|
Turborepo automatically detects frameworks and includes their conventional env vars.
|
||||||
|
|
||||||
|
### Inferred Variables by Framework
|
||||||
|
|
||||||
|
| Framework | Pattern |
|
||||||
|
| ---------------- | ------------------- |
|
||||||
|
| Next.js | `NEXT_PUBLIC_*` |
|
||||||
|
| Vite | `VITE_*` |
|
||||||
|
| Create React App | `REACT_APP_*` |
|
||||||
|
| Gatsby | `GATSBY_*` |
|
||||||
|
| Nuxt | `NUXT_*`, `NITRO_*` |
|
||||||
|
| Expo | `EXPO_PUBLIC_*` |
|
||||||
|
| Astro | `PUBLIC_*` |
|
||||||
|
| SvelteKit | `PUBLIC_*` |
|
||||||
|
| Remix | `REMIX_*` |
|
||||||
|
| Redwood | `REDWOOD_ENV_*` |
|
||||||
|
| Sanity | `SANITY_STUDIO_*` |
|
||||||
|
| Solid | `VITE_*` |
|
||||||
|
|
||||||
|
### Disabling Framework Inference
|
||||||
|
|
||||||
|
Globally via CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run build --framework-inference=false
|
||||||
|
```
|
||||||
|
|
||||||
|
Or exclude specific patterns in config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"env": ["!NEXT_PUBLIC_*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why Disable?
|
||||||
|
|
||||||
|
- You want explicit control over all env vars
|
||||||
|
- Framework vars shouldn't bust the cache (e.g., analytics IDs)
|
||||||
|
- Debugging unexpected cache misses
|
||||||
|
|
||||||
|
## Checking Environment Mode
|
||||||
|
|
||||||
|
Use `--dry` to see which vars affect each task:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run build --dry=json | jq '.tasks[].environmentVariables'
|
||||||
|
```
|
||||||
148
.agents/skills/turborepo/references/filtering/RULE.md
Normal file
148
.agents/skills/turborepo/references/filtering/RULE.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# Turborepo Filter Syntax Reference
|
||||||
|
|
||||||
|
## Running Only Changed Packages: `--affected`
|
||||||
|
|
||||||
|
**The primary way to run only changed packages is `--affected`:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run build/test/lint only in changed packages and their dependents
|
||||||
|
turbo run build test lint --affected
|
||||||
|
```
|
||||||
|
|
||||||
|
This compares your current branch to the default branch (usually `main` or `master`) and runs tasks in:
|
||||||
|
|
||||||
|
1. Packages with file changes
|
||||||
|
2. Packages that depend on changed packages (dependents)
|
||||||
|
|
||||||
|
### Why Include Dependents?
|
||||||
|
|
||||||
|
If you change `@repo/ui`, packages that import `@repo/ui` (like `apps/web`) need to re-run their tasks to verify they still work with the changes.
|
||||||
|
|
||||||
|
### Customizing --affected
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use a different base branch
|
||||||
|
turbo run build --affected --affected-base=origin/develop
|
||||||
|
|
||||||
|
# Use a different head (current state)
|
||||||
|
turbo run build --affected --affected-head=HEAD~5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common CI Pattern
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/ci.yml
|
||||||
|
- run: turbo run build test lint --affected
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the most efficient CI setup - only run tasks for what actually changed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual Git Comparison with --filter
|
||||||
|
|
||||||
|
For more control, use `--filter` with git comparison syntax:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Changed packages + dependents (same as --affected)
|
||||||
|
turbo run build --filter=...[origin/main]
|
||||||
|
|
||||||
|
# Only changed packages (no dependents)
|
||||||
|
turbo run build --filter=[origin/main]
|
||||||
|
|
||||||
|
# Changed packages + dependencies (packages they import)
|
||||||
|
turbo run build --filter=[origin/main]...
|
||||||
|
|
||||||
|
# Changed since last commit
|
||||||
|
turbo run build --filter=...[HEAD^1]
|
||||||
|
|
||||||
|
# Changed between two commits
|
||||||
|
turbo run build --filter=[a1b2c3d...e4f5g6h]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Comparison Syntax
|
||||||
|
|
||||||
|
| Syntax | Meaning |
|
||||||
|
| ------------- | ------------------------------------- |
|
||||||
|
| `[ref]` | Packages changed since `ref` |
|
||||||
|
| `...[ref]` | Changed packages + their dependents |
|
||||||
|
| `[ref]...` | Changed packages + their dependencies |
|
||||||
|
| `...[ref]...` | Dependencies, changed, AND dependents |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Other Filter Types
|
||||||
|
|
||||||
|
Filters select which packages to include in a `turbo run` invocation.
|
||||||
|
|
||||||
|
### Basic Syntax
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run build --filter=<package-name>
|
||||||
|
turbo run build -F <package-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple filters combine as a union (packages matching ANY filter run).
|
||||||
|
|
||||||
|
### By Package Name
|
||||||
|
|
||||||
|
```bash
|
||||||
|
--filter=web # exact match
|
||||||
|
--filter=@acme/* # scope glob
|
||||||
|
--filter=*-app # name glob
|
||||||
|
```
|
||||||
|
|
||||||
|
### By Directory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
--filter=./apps/* # all packages in apps/
|
||||||
|
--filter=./packages/ui # specific directory
|
||||||
|
```
|
||||||
|
|
||||||
|
### By Dependencies/Dependents
|
||||||
|
|
||||||
|
| Syntax | Meaning |
|
||||||
|
| ----------- | -------------------------------------- |
|
||||||
|
| `pkg...` | Package AND all its dependencies |
|
||||||
|
| `...pkg` | Package AND all its dependents |
|
||||||
|
| `...pkg...` | Dependencies, package, AND dependents |
|
||||||
|
| `^pkg...` | Only dependencies (exclude pkg itself) |
|
||||||
|
| `...^pkg` | Only dependents (exclude pkg itself) |
|
||||||
|
|
||||||
|
### Negation
|
||||||
|
|
||||||
|
Exclude packages with `!`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
--filter=!web # exclude web
|
||||||
|
--filter=./apps/* --filter=!admin # apps except admin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task Identifiers
|
||||||
|
|
||||||
|
Run a specific task in a specific package:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run web#build # only web's build task
|
||||||
|
turbo run web#build api#test # web build + api test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Combining Filters
|
||||||
|
|
||||||
|
Multiple `--filter` flags create a union:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run build --filter=web --filter=api # runs in both
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference: Changed Packages
|
||||||
|
|
||||||
|
| Goal | Command |
|
||||||
|
| ---------------------------------- | ----------------------------------------------------------- |
|
||||||
|
| Changed + dependents (recommended) | `turbo run build --affected` |
|
||||||
|
| Custom base branch | `turbo run build --affected --affected-base=origin/develop` |
|
||||||
|
| Only changed (no dependents) | `turbo run build --filter=[origin/main]` |
|
||||||
|
| Changed + dependencies | `turbo run build --filter=[origin/main]...` |
|
||||||
|
| Since last commit | `turbo run build --filter=...[HEAD^1]` |
|
||||||
152
.agents/skills/turborepo/references/filtering/patterns.md
Normal file
152
.agents/skills/turborepo/references/filtering/patterns.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# Common Filter Patterns
|
||||||
|
|
||||||
|
Practical examples for typical monorepo scenarios.
|
||||||
|
|
||||||
|
## Single Package
|
||||||
|
|
||||||
|
Run task in one package:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run build --filter=web
|
||||||
|
turbo run test --filter=@acme/api
|
||||||
|
```
|
||||||
|
|
||||||
|
## Package with Dependencies
|
||||||
|
|
||||||
|
Build a package and everything it depends on:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run build --filter=web...
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful for: ensuring all dependencies are built before the target.
|
||||||
|
|
||||||
|
## Package Dependents
|
||||||
|
|
||||||
|
Run in all packages that depend on a library:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run test --filter=...ui
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful for: testing consumers after changing a shared package.
|
||||||
|
|
||||||
|
## Dependents Only (Exclude Target)
|
||||||
|
|
||||||
|
Test packages that depend on ui, but not ui itself:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run test --filter=...^ui
|
||||||
|
```
|
||||||
|
|
||||||
|
## Changed Packages
|
||||||
|
|
||||||
|
Run only in packages with file changes since last commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run lint --filter=[HEAD^1]
|
||||||
|
```
|
||||||
|
|
||||||
|
Since a specific branch point:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run lint --filter=[main...HEAD]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Changed + Dependents (PR Builds)
|
||||||
|
|
||||||
|
Run in changed packages AND packages that depend on them:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run build test --filter=...[HEAD^1]
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the shortcut:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run build test --affected
|
||||||
|
```
|
||||||
|
|
||||||
|
## Directory-Based
|
||||||
|
|
||||||
|
Run in all apps:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run build --filter=./apps/*
|
||||||
|
```
|
||||||
|
|
||||||
|
Run in specific directories:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run build --filter=./apps/web --filter=./apps/api
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scope-Based
|
||||||
|
|
||||||
|
Run in all packages under a scope:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run build --filter=@acme/*
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exclusions
|
||||||
|
|
||||||
|
Run in all apps except admin:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run build --filter=./apps/* --filter=!admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Run everywhere except specific packages:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run lint --filter=!legacy-app --filter=!deprecated-pkg
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complex Combinations
|
||||||
|
|
||||||
|
Apps that changed, plus their dependents:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run build --filter=...[HEAD^1] --filter=./apps/*
|
||||||
|
```
|
||||||
|
|
||||||
|
All packages except docs, but only if changed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run build --filter=[main...HEAD] --filter=!docs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debugging Filters
|
||||||
|
|
||||||
|
Use `--dry` to see what would run without executing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run build --filter=web... --dry
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--dry=json` for machine-readable output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run build --filter=...[HEAD^1] --dry=json
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD Patterns
|
||||||
|
|
||||||
|
PR validation (most common):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run build test lint --affected
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy only changed apps:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run deploy --filter=./apps/* --filter=[main...HEAD]
|
||||||
|
```
|
||||||
|
|
||||||
|
Full rebuild of specific app and deps:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo run build --filter=production-app...
|
||||||
|
```
|
||||||
99
.agents/skills/turborepo/references/watch/RULE.md
Normal file
99
.agents/skills/turborepo/references/watch/RULE.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# turbo watch
|
||||||
|
|
||||||
|
Full docs: https://turborepo.dev/docs/reference/watch
|
||||||
|
|
||||||
|
Re-run tasks automatically when code changes. Dependency-aware.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo watch [tasks]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Watch and re-run build task when code changes
|
||||||
|
turbo watch build
|
||||||
|
|
||||||
|
# Watch multiple tasks
|
||||||
|
turbo watch build test lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Tasks re-run in order configured in `turbo.json` when source files change.
|
||||||
|
|
||||||
|
## With Persistent Tasks
|
||||||
|
|
||||||
|
Persistent tasks (`"persistent": true`) won't exit, so they can't be depended on. They work the same in `turbo watch` as `turbo run`.
|
||||||
|
|
||||||
|
### Dependency-Aware Persistent Tasks
|
||||||
|
|
||||||
|
If your tool has built-in watching (like `next dev`), use its watcher:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"dev": {
|
||||||
|
"persistent": true,
|
||||||
|
"cache": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Non-Dependency-Aware Tools
|
||||||
|
|
||||||
|
For tools that don't detect dependency changes, use `interruptible`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"dev": {
|
||||||
|
"persistent": true,
|
||||||
|
"interruptible": true,
|
||||||
|
"cache": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`turbo watch` will restart interruptible tasks when dependencies change.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
### Caching
|
||||||
|
|
||||||
|
Caching is experimental with watch mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turbo watch your-tasks --experimental-write-cache
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task Outputs in Source Control
|
||||||
|
|
||||||
|
If tasks write files tracked by git, watch mode may loop infinitely. Watch mode uses file hashes to prevent this but it's not foolproof.
|
||||||
|
|
||||||
|
**Recommendation**: Remove task outputs from git.
|
||||||
|
|
||||||
|
## vs turbo run
|
||||||
|
|
||||||
|
| Feature | `turbo run` | `turbo watch` |
|
||||||
|
| ----------------- | ----------- | ------------- |
|
||||||
|
| Runs once | Yes | No |
|
||||||
|
| Re-runs on change | No | Yes |
|
||||||
|
| Caching | Full | Experimental |
|
||||||
|
| Use case | CI, one-off | Development |
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Development Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run dev servers and watch for build changes
|
||||||
|
turbo watch dev build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type Checking During Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Watch and re-run type checks
|
||||||
|
turbo watch check-types
|
||||||
|
```
|
||||||
134
.agents/skills/upgrading-expo/SKILL.md
Normal file
134
.agents/skills/upgrading-expo/SKILL.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
---
|
||||||
|
name: upgrading-expo
|
||||||
|
description: Guidelines for upgrading Expo SDK versions and fixing dependency issues
|
||||||
|
version: 1.0.0
|
||||||
|
license: MIT
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- ./references/new-architecture.md -- SDK +53: New Architecture migration guide
|
||||||
|
- ./references/react-19.md -- SDK +54: React 19 changes (useContext → use, Context.Provider → Context, forwardRef removal)
|
||||||
|
- ./references/react-compiler.md -- SDK +54: React Compiler setup and migration guide
|
||||||
|
- ./references/native-tabs.md -- SDK +55: Native tabs changes (Icon/Label/Badge now accessed via NativeTabs.Trigger.\*)
|
||||||
|
- ./references/expo-av-to-audio.md -- Migrate audio playback and recording from expo-av to expo-audio
|
||||||
|
- ./references/expo-av-to-video.md -- Migrate video playback from expo-av to expo-video
|
||||||
|
|
||||||
|
## Beta/Preview Releases
|
||||||
|
|
||||||
|
Beta versions use `.preview` suffix (e.g., `55.0.0-preview.2`), published under `@next` tag.
|
||||||
|
|
||||||
|
Check if latest is beta: https://exp.host/--/api/v2/versions (look for `-preview` in `expoVersion`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo install expo@next --fix # install beta
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step-by-Step Upgrade Process
|
||||||
|
|
||||||
|
1. Upgrade Expo and dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo install expo@latest
|
||||||
|
npx expo install --fix
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run diagnostics: `npx expo-doctor`
|
||||||
|
|
||||||
|
3. Clear caches and reinstall
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo export -p ios --clear
|
||||||
|
rm -rf node_modules .expo
|
||||||
|
watchman watch-del-all
|
||||||
|
```
|
||||||
|
|
||||||
|
## Breaking Changes Checklist
|
||||||
|
|
||||||
|
- Check for removed APIs in release notes
|
||||||
|
- Update import paths for moved modules
|
||||||
|
- Review native module changes requiring prebuild
|
||||||
|
- Test all camera, audio, and video features
|
||||||
|
- Verify navigation still works correctly
|
||||||
|
|
||||||
|
## Prebuild for Native Changes
|
||||||
|
|
||||||
|
**First check if `ios/` and `android/` directories exist in the project.** If neither directory exists, the project uses Continuous Native Generation (CNG) and native projects are regenerated at build time — skip this section and "Clear caches for bare workflow" entirely.
|
||||||
|
|
||||||
|
If upgrading requires native changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo prebuild --clean
|
||||||
|
```
|
||||||
|
|
||||||
|
This regenerates the `ios` and `android` directories. Ensure the project is not a bare workflow app before running this command.
|
||||||
|
|
||||||
|
## Clear caches for bare workflow
|
||||||
|
|
||||||
|
These steps only apply when `ios/` and/or `android/` directories exist in the project:
|
||||||
|
|
||||||
|
- Clear the cocoapods cache for iOS: `cd ios && pod install --repo-update`
|
||||||
|
- Clear derived data for Xcode: `npx expo run:ios --no-build-cache`
|
||||||
|
- Clear the Gradle cache for Android: `cd android && ./gradlew clean`
|
||||||
|
|
||||||
|
## Housekeeping
|
||||||
|
|
||||||
|
- Review release notes for the target SDK version at https://expo.dev/changelog
|
||||||
|
- If using Expo SDK 54 or later, ensure react-native-worklets is installed — this is required for react-native-reanimated to work.
|
||||||
|
- Enable React Compiler in SDK 54+ by adding `"experiments": { "reactCompiler": true }` to app.json — it's stable and recommended
|
||||||
|
- Delete sdkVersion from `app.json` to let Expo manage it automatically
|
||||||
|
- Remove implicit packages from `package.json`: `@babel/core`, `babel-preset-expo`, `expo-constants`.
|
||||||
|
- If the babel.config.js only contains 'babel-preset-expo', delete the file
|
||||||
|
- If the metro.config.js only contains expo defaults, delete the file
|
||||||
|
|
||||||
|
## Deprecated Packages
|
||||||
|
|
||||||
|
| Old Package | Replacement |
|
||||||
|
| -------------------- | ---------------------------------------------------- |
|
||||||
|
| `expo-av` | `expo-audio` and `expo-video` |
|
||||||
|
| `expo-permissions` | Individual package permission APIs |
|
||||||
|
| `@expo/vector-icons` | `expo-symbols` (for SF Symbols) |
|
||||||
|
| `AsyncStorage` | `expo-sqlite/localStorage/install` |
|
||||||
|
| `expo-app-loading` | `expo-splash-screen` |
|
||||||
|
| expo-linear-gradient | experimental_backgroundImage + CSS gradients in View |
|
||||||
|
|
||||||
|
When migrating deprecated packages, update all code usage before removing the old package. For expo-av, consult the migration references to convert Audio.Sound to useAudioPlayer, Audio.Recording to useAudioRecorder, and Video components to VideoView with useVideoPlayer.
|
||||||
|
|
||||||
|
## expo.install.exclude
|
||||||
|
|
||||||
|
Check if package.json has excluded packages:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expo": { "install": { "exclude": ["react-native-reanimated"] } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Exclusions are often workarounds that may no longer be needed after upgrading. Review each one.
|
||||||
|
|
||||||
|
## Removing patches
|
||||||
|
|
||||||
|
Check if there are any outdated patches in the `patches/` directory. Remove them if they are no longer needed.
|
||||||
|
|
||||||
|
## Postcss
|
||||||
|
|
||||||
|
- `autoprefixer` isn't needed in SDK +53. Remove it from dependencies and check `postcss.config.js` or `postcss.config.mjs` to remove it from the plugins list.
|
||||||
|
- Use `postcss.config.mjs` in SDK +53.
|
||||||
|
|
||||||
|
## Metro
|
||||||
|
|
||||||
|
Remove redundant metro config options:
|
||||||
|
|
||||||
|
- resolver.unstable_enablePackageExports is enabled by default in SDK +53.
|
||||||
|
- `experimentalImportSupport` is enabled by default in SDK +54.
|
||||||
|
- `EXPO_USE_FAST_RESOLVER=1` is removed in SDK +54.
|
||||||
|
- cjs and mjs extensions are supported by default in SDK +50.
|
||||||
|
- Expo webpack is deprecated, migrate to [Expo Router and Metro web](https://docs.expo.dev/router/migrate/from-expo-webpack/).
|
||||||
|
|
||||||
|
## Hermes engine v1
|
||||||
|
|
||||||
|
Since SDK 55, users can opt-in to use Hermes engine v1 for improved runtime performance. This requires setting `useHermesV1: true` in the `expo-build-properties` config plugin, and may require a specific version of the `hermes-compiler` npm package. Hermes v1 will become a default in some future SDK release.
|
||||||
|
|
||||||
|
## New Architecture
|
||||||
|
|
||||||
|
The new architecture is enabled by default, the app.json field `"newArchEnabled": true` is no longer needed as it's the default. Expo Go only supports the new architecture as of SDK +53.
|
||||||
144
.agents/skills/upgrading-expo/references/expo-av-to-audio.md
Normal file
144
.agents/skills/upgrading-expo/references/expo-av-to-audio.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# Migrating from expo-av to expo-audio
|
||||||
|
|
||||||
|
## Imports
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Before
|
||||||
|
import { Audio } from "expo-av";
|
||||||
|
|
||||||
|
// After
|
||||||
|
import {
|
||||||
|
useAudioPlayer,
|
||||||
|
useAudioRecorder,
|
||||||
|
RecordingPresets,
|
||||||
|
AudioModule,
|
||||||
|
setAudioModeAsync,
|
||||||
|
} from "expo-audio";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Audio Playback
|
||||||
|
|
||||||
|
### Before (expo-av)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [sound, setSound] = useState<Audio.Sound>();
|
||||||
|
|
||||||
|
async function playSound() {
|
||||||
|
const { sound } = await Audio.Sound.createAsync(require("./audio.mp3"));
|
||||||
|
setSound(sound);
|
||||||
|
await sound.playAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return sound
|
||||||
|
? () => {
|
||||||
|
sound.unloadAsync();
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
}, [sound]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (expo-audio)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const player = useAudioPlayer(require("./audio.mp3"));
|
||||||
|
|
||||||
|
// Play
|
||||||
|
player.play();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Audio Recording
|
||||||
|
|
||||||
|
### Before (expo-av)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [recording, setRecording] = useState<Audio.Recording>();
|
||||||
|
|
||||||
|
async function startRecording() {
|
||||||
|
await Audio.requestPermissionsAsync();
|
||||||
|
await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true });
|
||||||
|
const { recording } = await Audio.Recording.createAsync(
|
||||||
|
Audio.RecordingOptionsPresets.HIGH_QUALITY,
|
||||||
|
);
|
||||||
|
setRecording(recording);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopRecording() {
|
||||||
|
await recording?.stopAndUnloadAsync();
|
||||||
|
const uri = recording?.getURI();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (expo-audio)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
|
||||||
|
|
||||||
|
async function startRecording() {
|
||||||
|
await AudioModule.requestRecordingPermissionsAsync();
|
||||||
|
await recorder.prepareToRecordAsync();
|
||||||
|
recorder.record();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopRecording() {
|
||||||
|
await recorder.stop();
|
||||||
|
const uri = recorder.uri;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Audio Mode
|
||||||
|
|
||||||
|
### Before (expo-av)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
await Audio.setAudioModeAsync({
|
||||||
|
allowsRecordingIOS: true,
|
||||||
|
playsInSilentModeIOS: true,
|
||||||
|
staysActiveInBackground: true,
|
||||||
|
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (expo-audio)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
await setAudioModeAsync({
|
||||||
|
playsInSilentMode: true,
|
||||||
|
shouldPlayInBackground: true,
|
||||||
|
interruptionMode: "doNotMix",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Mapping
|
||||||
|
|
||||||
|
| expo-av | expo-audio |
|
||||||
|
| --------------------------------- | ------------------------------------------------ |
|
||||||
|
| `Audio.Sound.createAsync()` | `useAudioPlayer(source)` |
|
||||||
|
| `sound.playAsync()` | `player.play()` |
|
||||||
|
| `sound.pauseAsync()` | `player.pause()` |
|
||||||
|
| `sound.setPositionAsync(ms)` | `player.seekTo(seconds)` |
|
||||||
|
| `sound.setVolumeAsync(vol)` | `player.volume = vol` |
|
||||||
|
| `sound.setRateAsync(rate)` | `player.playbackRate = rate` |
|
||||||
|
| `sound.setIsLoopingAsync(loop)` | `player.loop = loop` |
|
||||||
|
| `sound.unloadAsync()` | Automatic via hook |
|
||||||
|
| `playbackStatus.positionMillis` | `player.currentTime` (seconds) |
|
||||||
|
| `playbackStatus.durationMillis` | `player.duration` (seconds) |
|
||||||
|
| `playbackStatus.isPlaying` | `player.playing` |
|
||||||
|
| `Audio.Recording.createAsync()` | `useAudioRecorder(preset)` |
|
||||||
|
| `Audio.RecordingOptionsPresets.*` | `RecordingPresets.*` |
|
||||||
|
| `recording.stopAndUnloadAsync()` | `recorder.stop()` |
|
||||||
|
| `recording.getURI()` | `recorder.uri` |
|
||||||
|
| `Audio.requestPermissionsAsync()` | `AudioModule.requestRecordingPermissionsAsync()` |
|
||||||
|
|
||||||
|
## Key Differences
|
||||||
|
|
||||||
|
- **No auto-reset on finish**: After `play()` completes, the player stays paused at the end. To replay, call `player.seekTo(0)` then `play()`
|
||||||
|
- **Time in seconds**: expo-audio uses seconds, not milliseconds (matching web standards)
|
||||||
|
- **Immediate loading**: Audio loads immediately when the hook mounts—no explicit preloading needed
|
||||||
|
- **Automatic cleanup**: No need to call `unloadAsync()`, hooks handle resource cleanup on unmount
|
||||||
|
- **Multiple players**: Create multiple `useAudioPlayer` instances and store them—all load immediately
|
||||||
|
- **Direct property access**: Set volume, rate, loop directly on the player object (`player.volume = 0.5`)
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
https://docs.expo.dev/versions/latest/sdk/audio/
|
||||||
156
.agents/skills/upgrading-expo/references/expo-av-to-video.md
Normal file
156
.agents/skills/upgrading-expo/references/expo-av-to-video.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# Migrating from expo-av to expo-video
|
||||||
|
|
||||||
|
## Imports
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Before
|
||||||
|
import { Video, ResizeMode } from "expo-av";
|
||||||
|
|
||||||
|
// After
|
||||||
|
import { useVideoPlayer, VideoView, VideoSource } from "expo-video";
|
||||||
|
import { useEvent, useEventListener } from "expo";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Video Playback
|
||||||
|
|
||||||
|
### Before (expo-av)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const videoRef = useRef<Video>(null);
|
||||||
|
const [status, setStatus] = useState({});
|
||||||
|
|
||||||
|
<Video
|
||||||
|
ref={videoRef}
|
||||||
|
source={{ uri: "https://example.com/video.mp4" }}
|
||||||
|
style={{ width: 350, height: 200 }}
|
||||||
|
resizeMode={ResizeMode.CONTAIN}
|
||||||
|
isLooping
|
||||||
|
onPlaybackStatusUpdate={setStatus}
|
||||||
|
/>;
|
||||||
|
|
||||||
|
// Control
|
||||||
|
videoRef.current?.playAsync();
|
||||||
|
videoRef.current?.pauseAsync();
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (expo-video)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const player = useVideoPlayer("https://example.com/video.mp4", (player) => {
|
||||||
|
player.loop = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isPlaying } = useEvent(player, "playingChange", { isPlaying: player.playing });
|
||||||
|
|
||||||
|
<VideoView player={player} style={{ width: 350, height: 200 }} contentFit="contain" />;
|
||||||
|
|
||||||
|
// Control
|
||||||
|
player.play();
|
||||||
|
player.pause();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Status Updates
|
||||||
|
|
||||||
|
### Before (expo-av)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Video
|
||||||
|
onPlaybackStatusUpdate={(status) => {
|
||||||
|
if (status.isLoaded) {
|
||||||
|
console.log(status.positionMillis, status.durationMillis, status.isPlaying);
|
||||||
|
if (status.didJustFinish) console.log("finished");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (expo-video)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Reactive state
|
||||||
|
const { isPlaying } = useEvent(player, "playingChange", { isPlaying: player.playing });
|
||||||
|
|
||||||
|
// Side effects
|
||||||
|
useEventListener(player, "playToEnd", () => console.log("finished"));
|
||||||
|
|
||||||
|
// Direct access
|
||||||
|
console.log(player.currentTime, player.duration, player.playing);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local Files
|
||||||
|
|
||||||
|
### Before (expo-av)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Video source={require("./video.mp4")} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (expo-video)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const player = useVideoPlayer({ assetId: require("./video.mp4") });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fullscreen and PiP
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<VideoView
|
||||||
|
player={player}
|
||||||
|
allowsFullscreen
|
||||||
|
allowsPictureInPicture
|
||||||
|
onFullscreenEnter={() => {}}
|
||||||
|
onFullscreenExit={() => {}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
For PiP and background playback, add to app.json:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"plugins": [
|
||||||
|
["expo-video", { "supportsBackgroundPlayback": true, "supportsPictureInPicture": true }]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Mapping
|
||||||
|
|
||||||
|
| expo-av | expo-video |
|
||||||
|
| --------------------------------------- | --------------------------------------- |
|
||||||
|
| `<Video>` | `<VideoView>` |
|
||||||
|
| `ref={videoRef}` | `player={useVideoPlayer()}` |
|
||||||
|
| `source={{ uri }}` | Pass to `useVideoPlayer(uri)` |
|
||||||
|
| `resizeMode={ResizeMode.CONTAIN}` | `contentFit="contain"` |
|
||||||
|
| `isLooping` | `player.loop = true` |
|
||||||
|
| `shouldPlay` | `player.play()` in setup |
|
||||||
|
| `isMuted` | `player.muted = true` |
|
||||||
|
| `useNativeControls` | `nativeControls={true}` |
|
||||||
|
| `onPlaybackStatusUpdate` | `useEvent` / `useEventListener` |
|
||||||
|
| `videoRef.current.playAsync()` | `player.play()` |
|
||||||
|
| `videoRef.current.pauseAsync()` | `player.pause()` |
|
||||||
|
| `videoRef.current.replayAsync()` | `player.replay()` |
|
||||||
|
| `videoRef.current.setPositionAsync(ms)` | `player.currentTime = seconds` |
|
||||||
|
| `status.positionMillis` | `player.currentTime` (seconds) |
|
||||||
|
| `status.durationMillis` | `player.duration` (seconds) |
|
||||||
|
| `status.didJustFinish` | `useEventListener(player, 'playToEnd')` |
|
||||||
|
|
||||||
|
## Key Differences
|
||||||
|
|
||||||
|
- **Separate player and view**: Player logic decoupled from the view—one player can be used across multiple views
|
||||||
|
- **Time in seconds**: Uses seconds, not milliseconds
|
||||||
|
- **Event system**: Uses `useEvent`/`useEventListener` from `expo` instead of callback props
|
||||||
|
- **Video preloading**: Create a player without mounting a VideoView to preload for faster transitions
|
||||||
|
- **Built-in caching**: Set `useCaching: true` in VideoSource for persistent offline caching
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
- **Uninstall expo-av first**: On Android, having both expo-av and expo-video installed can cause VideoView to show a black screen. Uninstall expo-av before installing expo-video
|
||||||
|
- **Android: Reusing players**: Mounting the same player in multiple VideoViews simultaneously can cause black screens on Android (works on iOS)
|
||||||
|
- **Android: currentTime in setup**: Setting `player.currentTime` in the `useVideoPlayer` setup callback may not work on Android—set it after mount instead
|
||||||
|
- **Changing source**: Use `player.replace(newSource)` to change videos without recreating the player
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
https://docs.expo.dev/versions/latest/sdk/video/
|
||||||
111
.agents/skills/upgrading-expo/references/native-tabs.md
Normal file
111
.agents/skills/upgrading-expo/references/native-tabs.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# Native Tabs Migration (SDK 55)
|
||||||
|
|
||||||
|
In SDK 55, `Label`, `Icon`, `Badge`, and `VectorIcon` are now accessed as static properties on `NativeTabs.Trigger` rather than separate imports.
|
||||||
|
|
||||||
|
## Import Changes
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// SDK 53/54
|
||||||
|
import { NativeTabs, Icon, Label, Badge, VectorIcon } from "expo-router/unstable-native-tabs";
|
||||||
|
|
||||||
|
// SDK 55+
|
||||||
|
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Changes
|
||||||
|
|
||||||
|
| SDK 53/54 | SDK 55+ |
|
||||||
|
| ---------------- | ----------------------------------- |
|
||||||
|
| `<Icon />` | `<NativeTabs.Trigger.Icon />` |
|
||||||
|
| `<Label />` | `<NativeTabs.Trigger.Label />` |
|
||||||
|
| `<Badge />` | `<NativeTabs.Trigger.Badge />` |
|
||||||
|
| `<VectorIcon />` | `<NativeTabs.Trigger.VectorIcon />` |
|
||||||
|
| (n/a) | `<NativeTabs.BottomAccessory />` |
|
||||||
|
|
||||||
|
## New in SDK 55
|
||||||
|
|
||||||
|
### BottomAccessory
|
||||||
|
|
||||||
|
New component for Apple Music-style mini players on iOS +26 that float above the tab bar:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<NativeTabs>
|
||||||
|
<NativeTabs.BottomAccessory>{/* Content above tabs */}</NativeTabs.BottomAccessory>
|
||||||
|
<NativeTabs.Trigger name="(index)">
|
||||||
|
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
</NativeTabs>
|
||||||
|
```
|
||||||
|
|
||||||
|
On Android and web, this component will render as a no-op. Position a view absolutely above the tab bar instead.
|
||||||
|
|
||||||
|
### Icon `md` Prop
|
||||||
|
|
||||||
|
New `md` prop for Material icon glyphs on Android (alongside existing `drawable`):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<NativeTabs.Trigger.Icon sf="house" md="home" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Full Migration Example
|
||||||
|
|
||||||
|
### Before (SDK 53/54)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { NativeTabs, Icon, Label, Badge } from "expo-router/unstable-native-tabs";
|
||||||
|
|
||||||
|
export default function TabLayout() {
|
||||||
|
return (
|
||||||
|
<NativeTabs minimizeBehavior="onScrollDown">
|
||||||
|
<NativeTabs.Trigger name="(index)">
|
||||||
|
<Label>Home</Label>
|
||||||
|
<Icon sf="house.fill" />
|
||||||
|
<Badge>3</Badge>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
<NativeTabs.Trigger name="(settings)">
|
||||||
|
<Label>Settings</Label>
|
||||||
|
<Icon sf="gear" />
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
<NativeTabs.Trigger name="(search)" role="search">
|
||||||
|
<Label>Search</Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
</NativeTabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (SDK 55+)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||||
|
|
||||||
|
export default function TabLayout() {
|
||||||
|
return (
|
||||||
|
<NativeTabs minimizeBehavior="onScrollDown">
|
||||||
|
<NativeTabs.Trigger name="(index)">
|
||||||
|
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||||
|
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
|
||||||
|
<NativeTabs.Trigger.Badge>3</NativeTabs.Trigger.Badge>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
<NativeTabs.Trigger name="(settings)">
|
||||||
|
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
||||||
|
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
<NativeTabs.Trigger name="(search)" role="search">
|
||||||
|
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
</NativeTabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Checklist
|
||||||
|
|
||||||
|
1. Remove `Icon`, `Label`, `Badge`, `VectorIcon` from imports
|
||||||
|
2. Keep only `NativeTabs` import from `expo-router/unstable-native-tabs`
|
||||||
|
3. Replace `<Icon />` with `<NativeTabs.Trigger.Icon />`
|
||||||
|
4. Replace `<Label />` with `<NativeTabs.Trigger.Label />`
|
||||||
|
5. Replace `<Badge />` with `<NativeTabs.Trigger.Badge />`
|
||||||
|
6. Replace `<VectorIcon />` with `<NativeTabs.Trigger.VectorIcon />`
|
||||||
|
|
||||||
|
- Read docs for more info https://docs.expo.dev/versions/v55.0.0/sdk/router-native-tabs/
|
||||||
79
.agents/skills/upgrading-expo/references/new-architecture.md
Normal file
79
.agents/skills/upgrading-expo/references/new-architecture.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# New Architecture
|
||||||
|
|
||||||
|
The New Architecture is enabled by default in Expo SDK 53+. It replaces the legacy bridge with a faster, synchronous communication layer between JavaScript and native code.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Full guide: https://docs.expo.dev/guides/new-architecture/
|
||||||
|
|
||||||
|
## What Changed
|
||||||
|
|
||||||
|
- **JSI (JavaScript Interface)** — Direct synchronous calls between JS and native
|
||||||
|
- **Fabric** — New rendering system with concurrent features
|
||||||
|
- **TurboModules** — Lazy-loaded native modules with type safety
|
||||||
|
|
||||||
|
## SDK Compatibility
|
||||||
|
|
||||||
|
| SDK Version | New Architecture Status |
|
||||||
|
| ----------- | ----------------------- |
|
||||||
|
| SDK 53+ | Enabled by default |
|
||||||
|
| SDK 52 | Opt-in via app.json |
|
||||||
|
| SDK 51- | Experimental |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
New Architecture is enabled by default. To explicitly disable (not recommended):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"newArchEnabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Expo Go
|
||||||
|
|
||||||
|
Expo Go only supports the New Architecture as of SDK 53. Apps using the old architecture must use development builds.
|
||||||
|
|
||||||
|
## Common Migration Issues
|
||||||
|
|
||||||
|
### Native Module Compatibility
|
||||||
|
|
||||||
|
Some older native modules may not support the New Architecture. Check:
|
||||||
|
|
||||||
|
1. Module documentation for New Architecture support
|
||||||
|
2. GitHub issues for compatibility discussions
|
||||||
|
3. Consider alternatives if module is unmaintained
|
||||||
|
|
||||||
|
### Reanimated
|
||||||
|
|
||||||
|
React Native Reanimated requires `react-native-worklets` in SDK 54+:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo install react-native-worklets
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout Animations
|
||||||
|
|
||||||
|
Some layout animations behave differently. Test thoroughly after upgrading.
|
||||||
|
|
||||||
|
## Verifying New Architecture
|
||||||
|
|
||||||
|
Check if New Architecture is active:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
// Returns true if Fabric is enabled
|
||||||
|
const isNewArch = global._IS_FABRIC !== undefined;
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify from the command line if the currently running app uses the New Architecture: `bunx xcobra expo eval "_IS_FABRIC"` -> `true`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
1. **Clear caches** — `npx expo start --clear`
|
||||||
|
2. **Clean prebuild** — `npx expo prebuild --clean`
|
||||||
|
3. **Check native modules** — Ensure all dependencies support New Architecture
|
||||||
|
4. **Review console warnings** — Legacy modules log compatibility warnings
|
||||||
79
.agents/skills/upgrading-expo/references/react-19.md
Normal file
79
.agents/skills/upgrading-expo/references/react-19.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# React 19
|
||||||
|
|
||||||
|
React 19 is included in Expo SDK 54. This release simplifies several common patterns.
|
||||||
|
|
||||||
|
## Context Changes
|
||||||
|
|
||||||
|
### useContext → use
|
||||||
|
|
||||||
|
The `use` hook replaces `useContext`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Before (React 18)
|
||||||
|
import { useContext } from "react";
|
||||||
|
const value = useContext(MyContext);
|
||||||
|
|
||||||
|
// After (React 19)
|
||||||
|
import { use } from "react";
|
||||||
|
const value = use(MyContext);
|
||||||
|
```
|
||||||
|
|
||||||
|
- The `use` hook can also read promises, enabling Suspense-based data fetching.
|
||||||
|
- `use` can be called conditionally, this simplifies components that consume multiple contexts.
|
||||||
|
|
||||||
|
### Context.Provider → Context
|
||||||
|
|
||||||
|
Context providers no longer need the `.Provider` suffix:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Before (React 18)
|
||||||
|
<ThemeContext.Provider value={theme}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
|
||||||
|
// After (React 19)
|
||||||
|
<ThemeContext value={theme}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext>
|
||||||
|
```
|
||||||
|
|
||||||
|
## ref as a Prop
|
||||||
|
|
||||||
|
### Removing forwardRef
|
||||||
|
|
||||||
|
Components can now receive `ref` as a regular prop. `forwardRef` is no longer needed:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Before (React 18)
|
||||||
|
import { forwardRef } from "react";
|
||||||
|
|
||||||
|
const Input = forwardRef<TextInput, Props>((props, ref) => {
|
||||||
|
return <TextInput ref={ref} {...props} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
// After (React 19)
|
||||||
|
function Input({ ref, ...props }: Props & { ref?: React.Ref<TextInput> }) {
|
||||||
|
return <TextInput ref={ref} {...props} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration Steps
|
||||||
|
|
||||||
|
1. Remove `forwardRef` wrapper
|
||||||
|
2. Add `ref` to the props destructuring
|
||||||
|
3. Update the type to include `ref?: React.Ref<T>`
|
||||||
|
|
||||||
|
## Other React 19 Features
|
||||||
|
|
||||||
|
- **Actions** — Functions that handle async transitions
|
||||||
|
- **useOptimistic** — Optimistic UI updates
|
||||||
|
- **useFormStatus** — Form submission state (web)
|
||||||
|
- **Document Metadata** — Native `<title>` and `<meta>` support (web)
|
||||||
|
|
||||||
|
## Cleanup Checklist
|
||||||
|
|
||||||
|
When upgrading to SDK 54:
|
||||||
|
|
||||||
|
- [ ] Replace `useContext` with `use`
|
||||||
|
- [ ] Remove `.Provider` from Context components
|
||||||
|
- [ ] Remove `forwardRef` wrappers, use `ref` prop instead
|
||||||
59
.agents/skills/upgrading-expo/references/react-compiler.md
Normal file
59
.agents/skills/upgrading-expo/references/react-compiler.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# React Compiler
|
||||||
|
|
||||||
|
React Compiler is stable in Expo SDK 54 and later. It automatically memoizes components and hooks, eliminating the need for manual `useMemo`, `useCallback`, and `React.memo`.
|
||||||
|
|
||||||
|
## Enabling React Compiler
|
||||||
|
|
||||||
|
Add to `app.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"experiments": {
|
||||||
|
"reactCompiler": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## What React Compiler Does
|
||||||
|
|
||||||
|
- Automatically memoizes components and values
|
||||||
|
- Eliminates unnecessary re-renders
|
||||||
|
- Removes the need for manual `useMemo` and `useCallback`
|
||||||
|
- Works with existing code without modifications
|
||||||
|
|
||||||
|
## Cleanup After Enabling
|
||||||
|
|
||||||
|
Once React Compiler is enabled, you can remove manual memoization:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Before (manual memoization)
|
||||||
|
const memoizedValue = useMemo(() => computeExpensive(a, b), [a, b]);
|
||||||
|
const memoizedCallback = useCallback(() => doSomething(a), [a]);
|
||||||
|
const MemoizedComponent = React.memo(MyComponent);
|
||||||
|
|
||||||
|
// After (React Compiler handles it)
|
||||||
|
const value = computeExpensive(a, b);
|
||||||
|
const callback = () => doSomething(a);
|
||||||
|
// Just use MyComponent directly
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Expo SDK 54 or later
|
||||||
|
- New Architecture enabled (default in SDK 54+)
|
||||||
|
|
||||||
|
## Verifying It's Working
|
||||||
|
|
||||||
|
React Compiler runs at build time. Check the Metro bundler output for compilation messages. You can also use React DevTools to verify components are being optimized.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If you encounter issues:
|
||||||
|
|
||||||
|
1. Ensure New Architecture is enabled
|
||||||
|
2. Clear Metro cache: `npx expo start --clear`
|
||||||
|
3. Check for incompatible patterns in your code (rare)
|
||||||
|
|
||||||
|
React Compiler is designed to work with idiomatic React code. If it can't safely optimize a component, it skips that component without breaking your app.
|
||||||
917
.agents/skills/vercel-composition-patterns/AGENTS.md
Normal file
917
.agents/skills/vercel-composition-patterns/AGENTS.md
Normal file
@@ -0,0 +1,917 @@
|
|||||||
|
# React Composition Patterns
|
||||||
|
|
||||||
|
**Version 1.0.0**
|
||||||
|
Engineering
|
||||||
|
January 2026
|
||||||
|
|
||||||
|
> **Note:**
|
||||||
|
> This document is mainly for agents and LLMs to follow when maintaining,
|
||||||
|
> generating, or refactoring React codebases using composition. Humans
|
||||||
|
> may also find it useful, but guidance here is optimized for automation
|
||||||
|
> and consistency by AI-assisted workflows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abstract
|
||||||
|
|
||||||
|
Composition patterns for building flexible, maintainable React components. Avoid boolean prop proliferation by using compound components, lifting state, and composing internals. These patterns make codebases easier for both humans and AI agents to work with as they scale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Component Architecture](#1-component-architecture) — **HIGH**
|
||||||
|
- 1.1 [Avoid Boolean Prop Proliferation](#11-avoid-boolean-prop-proliferation)
|
||||||
|
- 1.2 [Use Compound Components](#12-use-compound-components)
|
||||||
|
2. [State Management](#2-state-management) — **MEDIUM**
|
||||||
|
- 2.1 [Decouple State Management from UI](#21-decouple-state-management-from-ui)
|
||||||
|
- 2.2 [Define Generic Context Interfaces for Dependency Injection](#22-define-generic-context-interfaces-for-dependency-injection)
|
||||||
|
- 2.3 [Lift State into Provider Components](#23-lift-state-into-provider-components)
|
||||||
|
3. [Implementation Patterns](#3-implementation-patterns) — **MEDIUM**
|
||||||
|
- 3.1 [Create Explicit Component Variants](#31-create-explicit-component-variants)
|
||||||
|
- 3.2 [Prefer Composing Children Over Render Props](#32-prefer-composing-children-over-render-props)
|
||||||
|
4. [React 19 APIs](#4-react-19-apis) — **MEDIUM**
|
||||||
|
- 4.1 [React 19 API Changes](#41-react-19-api-changes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Component Architecture
|
||||||
|
|
||||||
|
**Impact: HIGH**
|
||||||
|
|
||||||
|
Fundamental patterns for structuring components to avoid prop
|
||||||
|
proliferation and enable flexible composition.
|
||||||
|
|
||||||
|
### 1.1 Avoid Boolean Prop Proliferation
|
||||||
|
|
||||||
|
**Impact: CRITICAL (prevents unmaintainable component variants)**
|
||||||
|
|
||||||
|
Don't add boolean props like `isThread`, `isEditing`, `isDMThread` to customize
|
||||||
|
|
||||||
|
component behavior. Each boolean doubles possible states and creates
|
||||||
|
|
||||||
|
unmaintainable conditional logic. Use composition instead.
|
||||||
|
|
||||||
|
**Incorrect: boolean props create exponential complexity**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Composer({
|
||||||
|
onSubmit,
|
||||||
|
isThread,
|
||||||
|
channelId,
|
||||||
|
isDMThread,
|
||||||
|
dmId,
|
||||||
|
isEditing,
|
||||||
|
isForwarding,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<form>
|
||||||
|
<Header />
|
||||||
|
<Input />
|
||||||
|
{isDMThread ? (
|
||||||
|
<AlsoSendToDMField id={dmId} />
|
||||||
|
) : isThread ? (
|
||||||
|
<AlsoSendToChannelField id={channelId} />
|
||||||
|
) : null}
|
||||||
|
{isEditing ? <EditActions /> : isForwarding ? <ForwardActions /> : <DefaultActions />}
|
||||||
|
<Footer onSubmit={onSubmit} />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct: composition eliminates conditionals**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Channel composer
|
||||||
|
function ChannelComposer() {
|
||||||
|
return (
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Header />
|
||||||
|
<Composer.Input />
|
||||||
|
<Composer.Footer>
|
||||||
|
<Composer.Attachments />
|
||||||
|
<Composer.Formatting />
|
||||||
|
<Composer.Emojis />
|
||||||
|
<Composer.Submit />
|
||||||
|
</Composer.Footer>
|
||||||
|
</Composer.Frame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thread composer - adds "also send to channel" field
|
||||||
|
function ThreadComposer({ channelId }: { channelId: string }) {
|
||||||
|
return (
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Header />
|
||||||
|
<Composer.Input />
|
||||||
|
<AlsoSendToChannelField id={channelId} />
|
||||||
|
<Composer.Footer>
|
||||||
|
<Composer.Formatting />
|
||||||
|
<Composer.Emojis />
|
||||||
|
<Composer.Submit />
|
||||||
|
</Composer.Footer>
|
||||||
|
</Composer.Frame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit composer - different footer actions
|
||||||
|
function EditComposer() {
|
||||||
|
return (
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Input />
|
||||||
|
<Composer.Footer>
|
||||||
|
<Composer.Formatting />
|
||||||
|
<Composer.Emojis />
|
||||||
|
<Composer.CancelEdit />
|
||||||
|
<Composer.SaveEdit />
|
||||||
|
</Composer.Footer>
|
||||||
|
</Composer.Frame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each variant is explicit about what it renders. We can share internals without
|
||||||
|
|
||||||
|
sharing a single monolithic parent.
|
||||||
|
|
||||||
|
### 1.2 Use Compound Components
|
||||||
|
|
||||||
|
**Impact: HIGH (enables flexible composition without prop drilling)**
|
||||||
|
|
||||||
|
Structure complex components as compound components with a shared context. Each
|
||||||
|
|
||||||
|
subcomponent accesses shared state via context, not props. Consumers compose the
|
||||||
|
|
||||||
|
pieces they need.
|
||||||
|
|
||||||
|
**Incorrect: monolithic component with render props**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Composer({
|
||||||
|
renderHeader,
|
||||||
|
renderFooter,
|
||||||
|
renderActions,
|
||||||
|
showAttachments,
|
||||||
|
showFormatting,
|
||||||
|
showEmojis,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<form>
|
||||||
|
{renderHeader?.()}
|
||||||
|
<Input />
|
||||||
|
{showAttachments && <Attachments />}
|
||||||
|
{renderFooter ? (
|
||||||
|
renderFooter()
|
||||||
|
) : (
|
||||||
|
<Footer>
|
||||||
|
{showFormatting && <Formatting />}
|
||||||
|
{showEmojis && <Emojis />}
|
||||||
|
{renderActions?.()}
|
||||||
|
</Footer>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct: compound components with shared context**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const ComposerContext = createContext<ComposerContextValue | null>(null);
|
||||||
|
|
||||||
|
function ComposerProvider({ children, state, actions, meta }: ProviderProps) {
|
||||||
|
return <ComposerContext value={{ state, actions, meta }}>{children}</ComposerContext>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComposerFrame({ children }: { children: React.ReactNode }) {
|
||||||
|
return <form>{children}</form>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComposerInput() {
|
||||||
|
const {
|
||||||
|
state,
|
||||||
|
actions: { update },
|
||||||
|
meta: { inputRef },
|
||||||
|
} = use(ComposerContext);
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
value={state.input}
|
||||||
|
onChangeText={(text) => update((s) => ({ ...s, input: text }))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComposerSubmit() {
|
||||||
|
const {
|
||||||
|
actions: { submit },
|
||||||
|
} = use(ComposerContext);
|
||||||
|
return <Button onPress={submit}>Send</Button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export as compound component
|
||||||
|
const Composer = {
|
||||||
|
Provider: ComposerProvider,
|
||||||
|
Frame: ComposerFrame,
|
||||||
|
Input: ComposerInput,
|
||||||
|
Submit: ComposerSubmit,
|
||||||
|
Header: ComposerHeader,
|
||||||
|
Footer: ComposerFooter,
|
||||||
|
Attachments: ComposerAttachments,
|
||||||
|
Formatting: ComposerFormatting,
|
||||||
|
Emojis: ComposerEmojis,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Composer.Provider state={state} actions={actions} meta={meta}>
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Header />
|
||||||
|
<Composer.Input />
|
||||||
|
<Composer.Footer>
|
||||||
|
<Composer.Formatting />
|
||||||
|
<Composer.Submit />
|
||||||
|
</Composer.Footer>
|
||||||
|
</Composer.Frame>
|
||||||
|
</Composer.Provider>
|
||||||
|
```
|
||||||
|
|
||||||
|
Consumers explicitly compose exactly what they need. No hidden conditionals. And the state, actions and meta are dependency-injected by a parent provider, allowing multiple usages of the same component structure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. State Management
|
||||||
|
|
||||||
|
**Impact: MEDIUM**
|
||||||
|
|
||||||
|
Patterns for lifting state and managing shared context across
|
||||||
|
composed components.
|
||||||
|
|
||||||
|
### 2.1 Decouple State Management from UI
|
||||||
|
|
||||||
|
**Impact: MEDIUM (enables swapping state implementations without changing UI)**
|
||||||
|
|
||||||
|
The provider component should be the only place that knows how state is managed.
|
||||||
|
|
||||||
|
UI components consume the context interface—they don't know if state comes from
|
||||||
|
|
||||||
|
useState, Zustand, or a server sync.
|
||||||
|
|
||||||
|
**Incorrect: UI coupled to state implementation**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ChannelComposer({ channelId }: { channelId: string }) {
|
||||||
|
// UI component knows about global state implementation
|
||||||
|
const state = useGlobalChannelState(channelId);
|
||||||
|
const { submit, updateInput } = useChannelSync(channelId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Input value={state.input} onChange={(text) => sync.updateInput(text)} />
|
||||||
|
<Composer.Submit onPress={() => sync.submit()} />
|
||||||
|
</Composer.Frame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct: state management isolated in provider**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Provider handles all state management details
|
||||||
|
function ChannelProvider({
|
||||||
|
channelId,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
channelId: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { state, update, submit } = useGlobalChannel(channelId);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Composer.Provider state={state} actions={{ update, submit }} meta={{ inputRef }}>
|
||||||
|
{children}
|
||||||
|
</Composer.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI component only knows about the context interface
|
||||||
|
function ChannelComposer() {
|
||||||
|
return (
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Header />
|
||||||
|
<Composer.Input />
|
||||||
|
<Composer.Footer>
|
||||||
|
<Composer.Submit />
|
||||||
|
</Composer.Footer>
|
||||||
|
</Composer.Frame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
function Channel({ channelId }: { channelId: string }) {
|
||||||
|
return (
|
||||||
|
<ChannelProvider channelId={channelId}>
|
||||||
|
<ChannelComposer />
|
||||||
|
</ChannelProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Different providers, same UI:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Local state for ephemeral forms
|
||||||
|
function ForwardMessageProvider({ children }) {
|
||||||
|
const [state, setState] = useState(initialState);
|
||||||
|
const forwardMessage = useForwardMessage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Composer.Provider state={state} actions={{ update: setState, submit: forwardMessage }}>
|
||||||
|
{children}
|
||||||
|
</Composer.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global synced state for channels
|
||||||
|
function ChannelProvider({ channelId, children }) {
|
||||||
|
const { state, update, submit } = useGlobalChannel(channelId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Composer.Provider state={state} actions={{ update, submit }}>
|
||||||
|
{children}
|
||||||
|
</Composer.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The same `Composer.Input` component works with both providers because it only
|
||||||
|
|
||||||
|
depends on the context interface, not the implementation.
|
||||||
|
|
||||||
|
### 2.2 Define Generic Context Interfaces for Dependency Injection
|
||||||
|
|
||||||
|
**Impact: HIGH (enables dependency-injectable state across use-cases)**
|
||||||
|
|
||||||
|
Define a **generic interface** for your component context with three parts:
|
||||||
|
|
||||||
|
`state`, `actions`, and `meta`. This interface is a contract that any provider
|
||||||
|
|
||||||
|
can implement—enabling the same UI components to work with completely different
|
||||||
|
|
||||||
|
state implementations.
|
||||||
|
|
||||||
|
**Core principle:** Lift state, compose internals, make state
|
||||||
|
|
||||||
|
dependency-injectable.
|
||||||
|
|
||||||
|
**Incorrect: UI coupled to specific state implementation**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ComposerInput() {
|
||||||
|
// Tightly coupled to a specific hook
|
||||||
|
const { input, setInput } = useChannelComposerState();
|
||||||
|
return <TextInput value={input} onChangeText={setInput} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct: generic interface enables dependency injection**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Define a GENERIC interface that any provider can implement
|
||||||
|
interface ComposerState {
|
||||||
|
input: string;
|
||||||
|
attachments: Attachment[];
|
||||||
|
isSubmitting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComposerActions {
|
||||||
|
update: (updater: (state: ComposerState) => ComposerState) => void;
|
||||||
|
submit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComposerMeta {
|
||||||
|
inputRef: React.RefObject<TextInput>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComposerContextValue {
|
||||||
|
state: ComposerState;
|
||||||
|
actions: ComposerActions;
|
||||||
|
meta: ComposerMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ComposerContext = createContext<ComposerContextValue | null>(null);
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI components consume the interface, not the implementation:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ComposerInput() {
|
||||||
|
const {
|
||||||
|
state,
|
||||||
|
actions: { update },
|
||||||
|
meta,
|
||||||
|
} = use(ComposerContext);
|
||||||
|
|
||||||
|
// This component works with ANY provider that implements the interface
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
ref={meta.inputRef}
|
||||||
|
value={state.input}
|
||||||
|
onChangeText={(text) => update((s) => ({ ...s, input: text }))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Different providers implement the same interface:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Provider A: Local state for ephemeral forms
|
||||||
|
function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [state, setState] = useState(initialState);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const submit = useForwardMessage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComposerContext
|
||||||
|
value={{
|
||||||
|
state,
|
||||||
|
actions: { update: setState, submit },
|
||||||
|
meta: { inputRef },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ComposerContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider B: Global synced state for channels
|
||||||
|
function ChannelProvider({ channelId, children }: Props) {
|
||||||
|
const { state, update, submit } = useGlobalChannel(channelId);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComposerContext
|
||||||
|
value={{
|
||||||
|
state,
|
||||||
|
actions: { update, submit },
|
||||||
|
meta: { inputRef },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ComposerContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**The same composed UI works with both:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Works with ForwardMessageProvider (local state)
|
||||||
|
<ForwardMessageProvider>
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Input />
|
||||||
|
<Composer.Submit />
|
||||||
|
</Composer.Frame>
|
||||||
|
</ForwardMessageProvider>
|
||||||
|
|
||||||
|
// Works with ChannelProvider (global synced state)
|
||||||
|
<ChannelProvider channelId="abc">
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Input />
|
||||||
|
<Composer.Submit />
|
||||||
|
</Composer.Frame>
|
||||||
|
</ChannelProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Custom UI outside the component can access state and actions:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ForwardMessageDialog() {
|
||||||
|
return (
|
||||||
|
<ForwardMessageProvider>
|
||||||
|
<Dialog>
|
||||||
|
{/* The composer UI */}
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Input placeholder="Add a message, if you'd like." />
|
||||||
|
<Composer.Footer>
|
||||||
|
<Composer.Formatting />
|
||||||
|
<Composer.Emojis />
|
||||||
|
</Composer.Footer>
|
||||||
|
</Composer.Frame>
|
||||||
|
|
||||||
|
{/* Custom UI OUTSIDE the composer, but INSIDE the provider */}
|
||||||
|
<MessagePreview />
|
||||||
|
|
||||||
|
{/* Actions at the bottom of the dialog */}
|
||||||
|
<DialogActions>
|
||||||
|
<CancelButton />
|
||||||
|
<ForwardButton />
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</ForwardMessageProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This button lives OUTSIDE Composer.Frame but can still submit based on its context!
|
||||||
|
function ForwardButton() {
|
||||||
|
const {
|
||||||
|
actions: { submit },
|
||||||
|
} = use(ComposerContext);
|
||||||
|
return <Button onPress={submit}>Forward</Button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This preview lives OUTSIDE Composer.Frame but can read composer's state!
|
||||||
|
function MessagePreview() {
|
||||||
|
const { state } = use(ComposerContext);
|
||||||
|
return <Preview message={state.input} attachments={state.attachments} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The provider boundary is what matters—not the visual nesting. Components that
|
||||||
|
|
||||||
|
need shared state don't have to be inside the `Composer.Frame`. They just need
|
||||||
|
|
||||||
|
to be within the provider.
|
||||||
|
|
||||||
|
The `ForwardButton` and `MessagePreview` are not visually inside the composer
|
||||||
|
|
||||||
|
box, but they can still access its state and actions. This is the power of
|
||||||
|
|
||||||
|
lifting state into providers.
|
||||||
|
|
||||||
|
The UI is reusable bits you compose together. The state is dependency-injected
|
||||||
|
|
||||||
|
by the provider. Swap the provider, keep the UI.
|
||||||
|
|
||||||
|
### 2.3 Lift State into Provider Components
|
||||||
|
|
||||||
|
**Impact: HIGH (enables state sharing outside component boundaries)**
|
||||||
|
|
||||||
|
Move state management into dedicated provider components. This allows sibling
|
||||||
|
|
||||||
|
components outside the main UI to access and modify state without prop drilling
|
||||||
|
|
||||||
|
or awkward refs.
|
||||||
|
|
||||||
|
**Incorrect: state trapped inside component**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ForwardMessageComposer() {
|
||||||
|
const [state, setState] = useState(initialState);
|
||||||
|
const forwardMessage = useForwardMessage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Input />
|
||||||
|
<Composer.Footer />
|
||||||
|
</Composer.Frame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Problem: How does this button access composer state?
|
||||||
|
function ForwardMessageDialog() {
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<ForwardMessageComposer />
|
||||||
|
<MessagePreview /> {/* Needs composer state */}
|
||||||
|
<DialogActions>
|
||||||
|
<CancelButton />
|
||||||
|
<ForwardButton /> {/* Needs to call submit */}
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Incorrect: useEffect to sync state up**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ForwardMessageDialog() {
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<ForwardMessageComposer onInputChange={setInput} />
|
||||||
|
<MessagePreview input={input} />
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ForwardMessageComposer({ onInputChange }) {
|
||||||
|
const [state, setState] = useState(initialState);
|
||||||
|
useEffect(() => {
|
||||||
|
onInputChange(state.input); // Sync on every change 😬
|
||||||
|
}, [state.input]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Incorrect: reading state from ref on submit**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ForwardMessageDialog() {
|
||||||
|
const stateRef = useRef(null);
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<ForwardMessageComposer stateRef={stateRef} />
|
||||||
|
<ForwardButton onPress={() => submit(stateRef.current)} />
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct: state lifted to provider**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [state, setState] = useState(initialState);
|
||||||
|
const forwardMessage = useForwardMessage();
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Composer.Provider
|
||||||
|
state={state}
|
||||||
|
actions={{ update: setState, submit: forwardMessage }}
|
||||||
|
meta={{ inputRef }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Composer.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ForwardMessageDialog() {
|
||||||
|
return (
|
||||||
|
<ForwardMessageProvider>
|
||||||
|
<Dialog>
|
||||||
|
<ForwardMessageComposer />
|
||||||
|
<MessagePreview /> {/* Custom components can access state and actions */}
|
||||||
|
<DialogActions>
|
||||||
|
<CancelButton />
|
||||||
|
<ForwardButton /> {/* Custom components can access state and actions */}
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</ForwardMessageProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ForwardButton() {
|
||||||
|
const { actions } = use(Composer.Context);
|
||||||
|
return <Button onPress={actions.submit}>Forward</Button>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The ForwardButton lives outside the Composer.Frame but still has access to the
|
||||||
|
|
||||||
|
submit action because it's within the provider. Even though it's a one-off
|
||||||
|
|
||||||
|
component, it can still access the composer's state and actions from outside the
|
||||||
|
|
||||||
|
UI itself.
|
||||||
|
|
||||||
|
**Key insight:** Components that need shared state don't have to be visually
|
||||||
|
|
||||||
|
nested inside each other—they just need to be within the same provider.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Implementation Patterns
|
||||||
|
|
||||||
|
**Impact: MEDIUM**
|
||||||
|
|
||||||
|
Specific techniques for implementing compound components and
|
||||||
|
context providers.
|
||||||
|
|
||||||
|
### 3.1 Create Explicit Component Variants
|
||||||
|
|
||||||
|
**Impact: MEDIUM (self-documenting code, no hidden conditionals)**
|
||||||
|
|
||||||
|
Instead of one component with many boolean props, create explicit variant
|
||||||
|
|
||||||
|
components. Each variant composes the pieces it needs. The code documents
|
||||||
|
|
||||||
|
itself.
|
||||||
|
|
||||||
|
**Incorrect: one component, many modes**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// What does this component actually render?
|
||||||
|
<Composer isThread isEditing={false} channelId="abc" showAttachments showFormatting={false} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct: explicit variants**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Immediately clear what this renders
|
||||||
|
<ThreadComposer channelId="abc" />
|
||||||
|
|
||||||
|
// Or
|
||||||
|
<EditMessageComposer messageId="xyz" />
|
||||||
|
|
||||||
|
// Or
|
||||||
|
<ForwardMessageComposer messageId="123" />
|
||||||
|
```
|
||||||
|
|
||||||
|
Each implementation is unique, explicit and self-contained. Yet they can each
|
||||||
|
|
||||||
|
use shared parts.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ThreadComposer({ channelId }: { channelId: string }) {
|
||||||
|
return (
|
||||||
|
<ThreadProvider channelId={channelId}>
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Input />
|
||||||
|
<AlsoSendToChannelField channelId={channelId} />
|
||||||
|
<Composer.Footer>
|
||||||
|
<Composer.Formatting />
|
||||||
|
<Composer.Emojis />
|
||||||
|
<Composer.Submit />
|
||||||
|
</Composer.Footer>
|
||||||
|
</Composer.Frame>
|
||||||
|
</ThreadProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditMessageComposer({ messageId }: { messageId: string }) {
|
||||||
|
return (
|
||||||
|
<EditMessageProvider messageId={messageId}>
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Input />
|
||||||
|
<Composer.Footer>
|
||||||
|
<Composer.Formatting />
|
||||||
|
<Composer.Emojis />
|
||||||
|
<Composer.CancelEdit />
|
||||||
|
<Composer.SaveEdit />
|
||||||
|
</Composer.Footer>
|
||||||
|
</Composer.Frame>
|
||||||
|
</EditMessageProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ForwardMessageComposer({ messageId }: { messageId: string }) {
|
||||||
|
return (
|
||||||
|
<ForwardMessageProvider messageId={messageId}>
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Input placeholder="Add a message, if you'd like." />
|
||||||
|
<Composer.Footer>
|
||||||
|
<Composer.Formatting />
|
||||||
|
<Composer.Emojis />
|
||||||
|
<Composer.Mentions />
|
||||||
|
</Composer.Footer>
|
||||||
|
</Composer.Frame>
|
||||||
|
</ForwardMessageProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each variant is explicit about:
|
||||||
|
|
||||||
|
- What provider/state it uses
|
||||||
|
|
||||||
|
- What UI elements it includes
|
||||||
|
|
||||||
|
- What actions are available
|
||||||
|
|
||||||
|
No boolean prop combinations to reason about. No impossible states.
|
||||||
|
|
||||||
|
### 3.2 Prefer Composing Children Over Render Props
|
||||||
|
|
||||||
|
**Impact: MEDIUM (cleaner composition, better readability)**
|
||||||
|
|
||||||
|
Use `children` for composition instead of `renderX` props. Children are more
|
||||||
|
|
||||||
|
readable, compose naturally, and don't require understanding callback
|
||||||
|
|
||||||
|
signatures.
|
||||||
|
|
||||||
|
**Incorrect: render props**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Composer({
|
||||||
|
renderHeader,
|
||||||
|
renderFooter,
|
||||||
|
renderActions,
|
||||||
|
}: {
|
||||||
|
renderHeader?: () => React.ReactNode;
|
||||||
|
renderFooter?: () => React.ReactNode;
|
||||||
|
renderActions?: () => React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<form>
|
||||||
|
{renderHeader?.()}
|
||||||
|
<Input />
|
||||||
|
{renderFooter ? renderFooter() : <DefaultFooter />}
|
||||||
|
{renderActions?.()}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage is awkward and inflexible
|
||||||
|
return (
|
||||||
|
<Composer
|
||||||
|
renderHeader={() => <CustomHeader />}
|
||||||
|
renderFooter={() => (
|
||||||
|
<>
|
||||||
|
<Formatting />
|
||||||
|
<Emojis />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
renderActions={() => <SubmitButton />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct: compound components with children**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ComposerFrame({ children }: { children: React.ReactNode }) {
|
||||||
|
return <form>{children}</form>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComposerFooter({ children }: { children: React.ReactNode }) {
|
||||||
|
return <footer className="flex">{children}</footer>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage is flexible
|
||||||
|
return (
|
||||||
|
<Composer.Frame>
|
||||||
|
<CustomHeader />
|
||||||
|
<Composer.Input />
|
||||||
|
<Composer.Footer>
|
||||||
|
<Composer.Formatting />
|
||||||
|
<Composer.Emojis />
|
||||||
|
<SubmitButton />
|
||||||
|
</Composer.Footer>
|
||||||
|
</Composer.Frame>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**When render props are appropriate:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Render props work well when you need to pass data back
|
||||||
|
<List data={items} renderItem={({ item, index }) => <Item item={item} index={index} />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Use render props when the parent needs to provide data or state to the child.
|
||||||
|
|
||||||
|
Use children when composing static structure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. React 19 APIs
|
||||||
|
|
||||||
|
**Impact: MEDIUM**
|
||||||
|
|
||||||
|
React 19+ only. Don't use `forwardRef`; use `use()` instead of `useContext()`.
|
||||||
|
|
||||||
|
### 4.1 React 19 API Changes
|
||||||
|
|
||||||
|
**Impact: MEDIUM (cleaner component definitions and context usage)**
|
||||||
|
|
||||||
|
> **⚠️ React 19+ only.** Skip this if you're on React 18 or earlier.
|
||||||
|
|
||||||
|
In React 19, `ref` is now a regular prop (no `forwardRef` wrapper needed), and `use()` replaces `useContext()`.
|
||||||
|
|
||||||
|
**Incorrect: forwardRef in React 19**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const ComposerInput = forwardRef<TextInput, Props>((props, ref) => {
|
||||||
|
return <TextInput ref={ref} {...props} />;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct: ref as a regular prop**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ComposerInput({ ref, ...props }: Props & { ref?: React.Ref<TextInput> }) {
|
||||||
|
return <TextInput ref={ref} {...props} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Incorrect: useContext in React 19**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const value = useContext(MyContext);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct: use instead of useContext**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const value = use(MyContext);
|
||||||
|
```
|
||||||
|
|
||||||
|
`use()` can also be called conditionally, unlike `useContext()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
1. [https://react.dev](https://react.dev)
|
||||||
|
2. [https://react.dev/learn/passing-data-deeply-with-context](https://react.dev/learn/passing-data-deeply-with-context)
|
||||||
|
3. [https://react.dev/reference/react/use](https://react.dev/reference/react/use)
|
||||||
60
.agents/skills/vercel-composition-patterns/README.md
Normal file
60
.agents/skills/vercel-composition-patterns/README.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# React Composition Patterns
|
||||||
|
|
||||||
|
A structured repository for React composition patterns that scale. These
|
||||||
|
patterns help avoid boolean prop proliferation by using compound components,
|
||||||
|
lifting state, and composing internals.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
- `rules/` - Individual rule files (one per rule)
|
||||||
|
- `_sections.md` - Section metadata (titles, impacts, descriptions)
|
||||||
|
- `_template.md` - Template for creating new rules
|
||||||
|
- `area-description.md` - Individual rule files
|
||||||
|
- `metadata.json` - Document metadata (version, organization, abstract)
|
||||||
|
- **`AGENTS.md`** - Compiled output (generated)
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
### Component Architecture (CRITICAL)
|
||||||
|
|
||||||
|
- `architecture-avoid-boolean-props.md` - Don't add boolean props to customize
|
||||||
|
behavior
|
||||||
|
- `architecture-compound-components.md` - Structure as compound components with
|
||||||
|
shared context
|
||||||
|
|
||||||
|
### State Management (HIGH)
|
||||||
|
|
||||||
|
- `state-lift-state.md` - Lift state into provider components
|
||||||
|
- `state-context-interface.md` - Define clear context interfaces
|
||||||
|
(state/actions/meta)
|
||||||
|
- `state-decouple-implementation.md` - Decouple state management from UI
|
||||||
|
|
||||||
|
### Implementation Patterns (MEDIUM)
|
||||||
|
|
||||||
|
- `patterns-children-over-render-props.md` - Prefer children over renderX props
|
||||||
|
- `patterns-explicit-variants.md` - Create explicit component variants
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
1. **Composition over configuration** — Instead of adding props, let consumers
|
||||||
|
compose
|
||||||
|
2. **Lift your state** — State in providers, not trapped in components
|
||||||
|
3. **Compose your internals** — Subcomponents access context, not props
|
||||||
|
4. **Explicit variants** — Create ThreadComposer, EditComposer, not Composer
|
||||||
|
with isThread
|
||||||
|
|
||||||
|
## Creating a New Rule
|
||||||
|
|
||||||
|
1. Copy `rules/_template.md` to `rules/area-description.md`
|
||||||
|
2. Choose the appropriate area prefix:
|
||||||
|
- `architecture-` for Component Architecture
|
||||||
|
- `state-` for State Management
|
||||||
|
- `patterns-` for Implementation Patterns
|
||||||
|
3. Fill in the frontmatter and content
|
||||||
|
4. Ensure you have clear examples with explanations
|
||||||
|
|
||||||
|
## Impact Levels
|
||||||
|
|
||||||
|
- `CRITICAL` - Foundational patterns, prevents unmaintainable code
|
||||||
|
- `HIGH` - Significant maintainability improvements
|
||||||
|
- `MEDIUM` - Good practices for cleaner code
|
||||||
88
.agents/skills/vercel-composition-patterns/SKILL.md
Normal file
88
.agents/skills/vercel-composition-patterns/SKILL.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
name: vercel-composition-patterns
|
||||||
|
description: React composition patterns that scale. Use when refactoring components with
|
||||||
|
boolean prop proliferation, building flexible component libraries, or
|
||||||
|
designing reusable APIs. Triggers on tasks involving compound components,
|
||||||
|
render props, context providers, or component architecture. Includes React 19
|
||||||
|
API changes.
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: vercel
|
||||||
|
version: "1.0.0"
|
||||||
|
---
|
||||||
|
|
||||||
|
# React Composition Patterns
|
||||||
|
|
||||||
|
Composition patterns for building flexible, maintainable React components. Avoid
|
||||||
|
boolean prop proliferation by using compound components, lifting state, and
|
||||||
|
composing internals. These patterns make codebases easier for both humans and AI
|
||||||
|
agents to work with as they scale.
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
Reference these guidelines when:
|
||||||
|
|
||||||
|
- Refactoring components with many boolean props
|
||||||
|
- Building reusable component libraries
|
||||||
|
- Designing flexible component APIs
|
||||||
|
- Reviewing component architecture
|
||||||
|
- Working with compound components or context providers
|
||||||
|
|
||||||
|
## Rule Categories by Priority
|
||||||
|
|
||||||
|
| Priority | Category | Impact | Prefix |
|
||||||
|
| -------- | ----------------------- | ------ | --------------- |
|
||||||
|
| 1 | Component Architecture | HIGH | `architecture-` |
|
||||||
|
| 2 | State Management | MEDIUM | `state-` |
|
||||||
|
| 3 | Implementation Patterns | MEDIUM | `patterns-` |
|
||||||
|
| 4 | React 19 APIs | MEDIUM | `react19-` |
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### 1. Component Architecture (HIGH)
|
||||||
|
|
||||||
|
- `architecture-avoid-boolean-props` - Don't add boolean props to customize
|
||||||
|
behavior; use composition
|
||||||
|
- `architecture-compound-components` - Structure complex components with shared
|
||||||
|
context
|
||||||
|
|
||||||
|
### 2. State Management (MEDIUM)
|
||||||
|
|
||||||
|
- `state-decouple-implementation` - Provider is the only place that knows how
|
||||||
|
state is managed
|
||||||
|
- `state-context-interface` - Define generic interface with state, actions, meta
|
||||||
|
for dependency injection
|
||||||
|
- `state-lift-state` - Move state into provider components for sibling access
|
||||||
|
|
||||||
|
### 3. Implementation Patterns (MEDIUM)
|
||||||
|
|
||||||
|
- `patterns-explicit-variants` - Create explicit variant components instead of
|
||||||
|
boolean modes
|
||||||
|
- `patterns-children-over-render-props` - Use children for composition instead
|
||||||
|
of renderX props
|
||||||
|
|
||||||
|
### 4. React 19 APIs (MEDIUM)
|
||||||
|
|
||||||
|
> **⚠️ React 19+ only.** Skip this section if using React 18 or earlier.
|
||||||
|
|
||||||
|
- `react19-no-forwardref` - Don't use `forwardRef`; use `use()` instead of `useContext()`
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
Read individual rule files for detailed explanations and code examples:
|
||||||
|
|
||||||
|
```
|
||||||
|
rules/architecture-avoid-boolean-props.md
|
||||||
|
rules/state-context-interface.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Each rule file contains:
|
||||||
|
|
||||||
|
- Brief explanation of why it matters
|
||||||
|
- Incorrect code example with explanation
|
||||||
|
- Correct code example with explanation
|
||||||
|
- Additional context and references
|
||||||
|
|
||||||
|
## Full Compiled Document
|
||||||
|
|
||||||
|
For the complete guide with all rules expanded: `AGENTS.md`
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
title: Avoid Boolean Prop Proliferation
|
||||||
|
impact: CRITICAL
|
||||||
|
impactDescription: prevents unmaintainable component variants
|
||||||
|
tags: composition, props, architecture
|
||||||
|
---
|
||||||
|
|
||||||
|
## Avoid Boolean Prop Proliferation
|
||||||
|
|
||||||
|
Don't add boolean props like `isThread`, `isEditing`, `isDMThread` to customize
|
||||||
|
component behavior. Each boolean doubles possible states and creates
|
||||||
|
unmaintainable conditional logic. Use composition instead.
|
||||||
|
|
||||||
|
**Incorrect (boolean props create exponential complexity):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Composer({
|
||||||
|
onSubmit,
|
||||||
|
isThread,
|
||||||
|
channelId,
|
||||||
|
isDMThread,
|
||||||
|
dmId,
|
||||||
|
isEditing,
|
||||||
|
isForwarding,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<form>
|
||||||
|
<Header />
|
||||||
|
<Input />
|
||||||
|
{isDMThread ? (
|
||||||
|
<AlsoSendToDMField id={dmId} />
|
||||||
|
) : isThread ? (
|
||||||
|
<AlsoSendToChannelField id={channelId} />
|
||||||
|
) : null}
|
||||||
|
{isEditing ? <EditActions /> : isForwarding ? <ForwardActions /> : <DefaultActions />}
|
||||||
|
<Footer onSubmit={onSubmit} />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (composition eliminates conditionals):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Channel composer
|
||||||
|
function ChannelComposer() {
|
||||||
|
return (
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Header />
|
||||||
|
<Composer.Input />
|
||||||
|
<Composer.Footer>
|
||||||
|
<Composer.Attachments />
|
||||||
|
<Composer.Formatting />
|
||||||
|
<Composer.Emojis />
|
||||||
|
<Composer.Submit />
|
||||||
|
</Composer.Footer>
|
||||||
|
</Composer.Frame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thread composer - adds "also send to channel" field
|
||||||
|
function ThreadComposer({ channelId }: { channelId: string }) {
|
||||||
|
return (
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Header />
|
||||||
|
<Composer.Input />
|
||||||
|
<AlsoSendToChannelField id={channelId} />
|
||||||
|
<Composer.Footer>
|
||||||
|
<Composer.Formatting />
|
||||||
|
<Composer.Emojis />
|
||||||
|
<Composer.Submit />
|
||||||
|
</Composer.Footer>
|
||||||
|
</Composer.Frame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit composer - different footer actions
|
||||||
|
function EditComposer() {
|
||||||
|
return (
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Input />
|
||||||
|
<Composer.Footer>
|
||||||
|
<Composer.Formatting />
|
||||||
|
<Composer.Emojis />
|
||||||
|
<Composer.CancelEdit />
|
||||||
|
<Composer.SaveEdit />
|
||||||
|
</Composer.Footer>
|
||||||
|
</Composer.Frame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each variant is explicit about what it renders. We can share internals without
|
||||||
|
sharing a single monolithic parent.
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
---
|
||||||
|
title: Use Compound Components
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: enables flexible composition without prop drilling
|
||||||
|
tags: composition, compound-components, architecture
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use Compound Components
|
||||||
|
|
||||||
|
Structure complex components as compound components with a shared context. Each
|
||||||
|
subcomponent accesses shared state via context, not props. Consumers compose the
|
||||||
|
pieces they need.
|
||||||
|
|
||||||
|
**Incorrect (monolithic component with render props):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Composer({
|
||||||
|
renderHeader,
|
||||||
|
renderFooter,
|
||||||
|
renderActions,
|
||||||
|
showAttachments,
|
||||||
|
showFormatting,
|
||||||
|
showEmojis,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<form>
|
||||||
|
{renderHeader?.()}
|
||||||
|
<Input />
|
||||||
|
{showAttachments && <Attachments />}
|
||||||
|
{renderFooter ? (
|
||||||
|
renderFooter()
|
||||||
|
) : (
|
||||||
|
<Footer>
|
||||||
|
{showFormatting && <Formatting />}
|
||||||
|
{showEmojis && <Emojis />}
|
||||||
|
{renderActions?.()}
|
||||||
|
</Footer>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (compound components with shared context):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const ComposerContext = createContext<ComposerContextValue | null>(null);
|
||||||
|
|
||||||
|
function ComposerProvider({ children, state, actions, meta }: ProviderProps) {
|
||||||
|
return <ComposerContext value={{ state, actions, meta }}>{children}</ComposerContext>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComposerFrame({ children }: { children: React.ReactNode }) {
|
||||||
|
return <form>{children}</form>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComposerInput() {
|
||||||
|
const {
|
||||||
|
state,
|
||||||
|
actions: { update },
|
||||||
|
meta: { inputRef },
|
||||||
|
} = use(ComposerContext);
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
value={state.input}
|
||||||
|
onChangeText={(text) => update((s) => ({ ...s, input: text }))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComposerSubmit() {
|
||||||
|
const {
|
||||||
|
actions: { submit },
|
||||||
|
} = use(ComposerContext);
|
||||||
|
return <Button onPress={submit}>Send</Button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export as compound component
|
||||||
|
const Composer = {
|
||||||
|
Provider: ComposerProvider,
|
||||||
|
Frame: ComposerFrame,
|
||||||
|
Input: ComposerInput,
|
||||||
|
Submit: ComposerSubmit,
|
||||||
|
Header: ComposerHeader,
|
||||||
|
Footer: ComposerFooter,
|
||||||
|
Attachments: ComposerAttachments,
|
||||||
|
Formatting: ComposerFormatting,
|
||||||
|
Emojis: ComposerEmojis,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Composer.Provider state={state} actions={actions} meta={meta}>
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Header />
|
||||||
|
<Composer.Input />
|
||||||
|
<Composer.Footer>
|
||||||
|
<Composer.Formatting />
|
||||||
|
<Composer.Submit />
|
||||||
|
</Composer.Footer>
|
||||||
|
</Composer.Frame>
|
||||||
|
</Composer.Provider>
|
||||||
|
```
|
||||||
|
|
||||||
|
Consumers explicitly compose exactly what they need. No hidden conditionals. And the state, actions and meta are dependency-injected by a parent provider, allowing multiple usages of the same component structure.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
title: Prefer Composing Children Over Render Props
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: cleaner composition, better readability
|
||||||
|
tags: composition, children, render-props
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prefer Children Over Render Props
|
||||||
|
|
||||||
|
Use `children` for composition instead of `renderX` props. Children are more
|
||||||
|
readable, compose naturally, and don't require understanding callback
|
||||||
|
signatures.
|
||||||
|
|
||||||
|
**Incorrect (render props):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Composer({
|
||||||
|
renderHeader,
|
||||||
|
renderFooter,
|
||||||
|
renderActions,
|
||||||
|
}: {
|
||||||
|
renderHeader?: () => React.ReactNode;
|
||||||
|
renderFooter?: () => React.ReactNode;
|
||||||
|
renderActions?: () => React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<form>
|
||||||
|
{renderHeader?.()}
|
||||||
|
<Input />
|
||||||
|
{renderFooter ? renderFooter() : <DefaultFooter />}
|
||||||
|
{renderActions?.()}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage is awkward and inflexible
|
||||||
|
return (
|
||||||
|
<Composer
|
||||||
|
renderHeader={() => <CustomHeader />}
|
||||||
|
renderFooter={() => (
|
||||||
|
<>
|
||||||
|
<Formatting />
|
||||||
|
<Emojis />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
renderActions={() => <SubmitButton />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (compound components with children):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ComposerFrame({ children }: { children: React.ReactNode }) {
|
||||||
|
return <form>{children}</form>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComposerFooter({ children }: { children: React.ReactNode }) {
|
||||||
|
return <footer className="flex">{children}</footer>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage is flexible
|
||||||
|
return (
|
||||||
|
<Composer.Frame>
|
||||||
|
<CustomHeader />
|
||||||
|
<Composer.Input />
|
||||||
|
<Composer.Footer>
|
||||||
|
<Composer.Formatting />
|
||||||
|
<Composer.Emojis />
|
||||||
|
<SubmitButton />
|
||||||
|
</Composer.Footer>
|
||||||
|
</Composer.Frame>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**When render props are appropriate:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Render props work well when you need to pass data back
|
||||||
|
<List data={items} renderItem={({ item, index }) => <Item item={item} index={index} />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Use render props when the parent needs to provide data or state to the child.
|
||||||
|
Use children when composing static structure.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
title: Create Explicit Component Variants
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: self-documenting code, no hidden conditionals
|
||||||
|
tags: composition, variants, architecture
|
||||||
|
---
|
||||||
|
|
||||||
|
## Create Explicit Component Variants
|
||||||
|
|
||||||
|
Instead of one component with many boolean props, create explicit variant
|
||||||
|
components. Each variant composes the pieces it needs. The code documents
|
||||||
|
itself.
|
||||||
|
|
||||||
|
**Incorrect (one component, many modes):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// What does this component actually render?
|
||||||
|
<Composer isThread isEditing={false} channelId="abc" showAttachments showFormatting={false} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (explicit variants):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Immediately clear what this renders
|
||||||
|
<ThreadComposer channelId="abc" />
|
||||||
|
|
||||||
|
// Or
|
||||||
|
<EditMessageComposer messageId="xyz" />
|
||||||
|
|
||||||
|
// Or
|
||||||
|
<ForwardMessageComposer messageId="123" />
|
||||||
|
```
|
||||||
|
|
||||||
|
Each implementation is unique, explicit and self-contained. Yet they can each
|
||||||
|
use shared parts.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ThreadComposer({ channelId }: { channelId: string }) {
|
||||||
|
return (
|
||||||
|
<ThreadProvider channelId={channelId}>
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Input />
|
||||||
|
<AlsoSendToChannelField channelId={channelId} />
|
||||||
|
<Composer.Footer>
|
||||||
|
<Composer.Formatting />
|
||||||
|
<Composer.Emojis />
|
||||||
|
<Composer.Submit />
|
||||||
|
</Composer.Footer>
|
||||||
|
</Composer.Frame>
|
||||||
|
</ThreadProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditMessageComposer({ messageId }: { messageId: string }) {
|
||||||
|
return (
|
||||||
|
<EditMessageProvider messageId={messageId}>
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Input />
|
||||||
|
<Composer.Footer>
|
||||||
|
<Composer.Formatting />
|
||||||
|
<Composer.Emojis />
|
||||||
|
<Composer.CancelEdit />
|
||||||
|
<Composer.SaveEdit />
|
||||||
|
</Composer.Footer>
|
||||||
|
</Composer.Frame>
|
||||||
|
</EditMessageProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ForwardMessageComposer({ messageId }: { messageId: string }) {
|
||||||
|
return (
|
||||||
|
<ForwardMessageProvider messageId={messageId}>
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Input placeholder="Add a message, if you'd like." />
|
||||||
|
<Composer.Footer>
|
||||||
|
<Composer.Formatting />
|
||||||
|
<Composer.Emojis />
|
||||||
|
<Composer.Mentions />
|
||||||
|
</Composer.Footer>
|
||||||
|
</Composer.Frame>
|
||||||
|
</ForwardMessageProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each variant is explicit about:
|
||||||
|
|
||||||
|
- What provider/state it uses
|
||||||
|
- What UI elements it includes
|
||||||
|
- What actions are available
|
||||||
|
|
||||||
|
No boolean prop combinations to reason about. No impossible states.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
title: React 19 API Changes
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: cleaner component definitions and context usage
|
||||||
|
tags: react19, refs, context, hooks
|
||||||
|
---
|
||||||
|
|
||||||
|
## React 19 API Changes
|
||||||
|
|
||||||
|
> **⚠️ React 19+ only.** Skip this if you're on React 18 or earlier.
|
||||||
|
|
||||||
|
In React 19, `ref` is now a regular prop (no `forwardRef` wrapper needed), and `use()` replaces `useContext()`.
|
||||||
|
|
||||||
|
**Incorrect (forwardRef in React 19):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const ComposerInput = forwardRef<TextInput, Props>((props, ref) => {
|
||||||
|
return <TextInput ref={ref} {...props} />;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (ref as a regular prop):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ComposerInput({ ref, ...props }: Props & { ref?: React.Ref<TextInput> }) {
|
||||||
|
return <TextInput ref={ref} {...props} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Incorrect (useContext in React 19):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const value = useContext(MyContext);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (use instead of useContext):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const value = use(MyContext);
|
||||||
|
```
|
||||||
|
|
||||||
|
`use()` can also be called conditionally, unlike `useContext()`.
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
---
|
||||||
|
title: Define Generic Context Interfaces for Dependency Injection
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: enables dependency-injectable state across use-cases
|
||||||
|
tags: composition, context, state, typescript, dependency-injection
|
||||||
|
---
|
||||||
|
|
||||||
|
## Define Generic Context Interfaces for Dependency Injection
|
||||||
|
|
||||||
|
Define a **generic interface** for your component context with three parts:
|
||||||
|
`state`, `actions`, and `meta`. This interface is a contract that any provider
|
||||||
|
can implement—enabling the same UI components to work with completely different
|
||||||
|
state implementations.
|
||||||
|
|
||||||
|
**Core principle:** Lift state, compose internals, make state
|
||||||
|
dependency-injectable.
|
||||||
|
|
||||||
|
**Incorrect (UI coupled to specific state implementation):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ComposerInput() {
|
||||||
|
// Tightly coupled to a specific hook
|
||||||
|
const { input, setInput } = useChannelComposerState();
|
||||||
|
return <TextInput value={input} onChangeText={setInput} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (generic interface enables dependency injection):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Define a GENERIC interface that any provider can implement
|
||||||
|
interface ComposerState {
|
||||||
|
input: string;
|
||||||
|
attachments: Attachment[];
|
||||||
|
isSubmitting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComposerActions {
|
||||||
|
update: (updater: (state: ComposerState) => ComposerState) => void;
|
||||||
|
submit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComposerMeta {
|
||||||
|
inputRef: React.RefObject<TextInput>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComposerContextValue {
|
||||||
|
state: ComposerState;
|
||||||
|
actions: ComposerActions;
|
||||||
|
meta: ComposerMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ComposerContext = createContext<ComposerContextValue | null>(null);
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI components consume the interface, not the implementation:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ComposerInput() {
|
||||||
|
const {
|
||||||
|
state,
|
||||||
|
actions: { update },
|
||||||
|
meta,
|
||||||
|
} = use(ComposerContext);
|
||||||
|
|
||||||
|
// This component works with ANY provider that implements the interface
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
ref={meta.inputRef}
|
||||||
|
value={state.input}
|
||||||
|
onChangeText={(text) => update((s) => ({ ...s, input: text }))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Different providers implement the same interface:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Provider A: Local state for ephemeral forms
|
||||||
|
function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [state, setState] = useState(initialState);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const submit = useForwardMessage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComposerContext
|
||||||
|
value={{
|
||||||
|
state,
|
||||||
|
actions: { update: setState, submit },
|
||||||
|
meta: { inputRef },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ComposerContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider B: Global synced state for channels
|
||||||
|
function ChannelProvider({ channelId, children }: Props) {
|
||||||
|
const { state, update, submit } = useGlobalChannel(channelId);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComposerContext
|
||||||
|
value={{
|
||||||
|
state,
|
||||||
|
actions: { update, submit },
|
||||||
|
meta: { inputRef },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ComposerContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**The same composed UI works with both:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Works with ForwardMessageProvider (local state)
|
||||||
|
<ForwardMessageProvider>
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Input />
|
||||||
|
<Composer.Submit />
|
||||||
|
</Composer.Frame>
|
||||||
|
</ForwardMessageProvider>
|
||||||
|
|
||||||
|
// Works with ChannelProvider (global synced state)
|
||||||
|
<ChannelProvider channelId="abc">
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Input />
|
||||||
|
<Composer.Submit />
|
||||||
|
</Composer.Frame>
|
||||||
|
</ChannelProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Custom UI outside the component can access state and actions:**
|
||||||
|
|
||||||
|
The provider boundary is what matters—not the visual nesting. Components that
|
||||||
|
need shared state don't have to be inside the `Composer.Frame`. They just need
|
||||||
|
to be within the provider.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ForwardMessageDialog() {
|
||||||
|
return (
|
||||||
|
<ForwardMessageProvider>
|
||||||
|
<Dialog>
|
||||||
|
{/* The composer UI */}
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Input placeholder="Add a message, if you'd like." />
|
||||||
|
<Composer.Footer>
|
||||||
|
<Composer.Formatting />
|
||||||
|
<Composer.Emojis />
|
||||||
|
</Composer.Footer>
|
||||||
|
</Composer.Frame>
|
||||||
|
|
||||||
|
{/* Custom UI OUTSIDE the composer, but INSIDE the provider */}
|
||||||
|
<MessagePreview />
|
||||||
|
|
||||||
|
{/* Actions at the bottom of the dialog */}
|
||||||
|
<DialogActions>
|
||||||
|
<CancelButton />
|
||||||
|
<ForwardButton />
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</ForwardMessageProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This button lives OUTSIDE Composer.Frame but can still submit based on its context!
|
||||||
|
function ForwardButton() {
|
||||||
|
const {
|
||||||
|
actions: { submit },
|
||||||
|
} = use(ComposerContext);
|
||||||
|
return <Button onPress={submit}>Forward</Button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This preview lives OUTSIDE Composer.Frame but can read composer's state!
|
||||||
|
function MessagePreview() {
|
||||||
|
const { state } = use(ComposerContext);
|
||||||
|
return <Preview message={state.input} attachments={state.attachments} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `ForwardButton` and `MessagePreview` are not visually inside the composer
|
||||||
|
box, but they can still access its state and actions. This is the power of
|
||||||
|
lifting state into providers.
|
||||||
|
|
||||||
|
The UI is reusable bits you compose together. The state is dependency-injected
|
||||||
|
by the provider. Swap the provider, keep the UI.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
---
|
||||||
|
title: Decouple State Management from UI
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: enables swapping state implementations without changing UI
|
||||||
|
tags: composition, state, architecture
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decouple State Management from UI
|
||||||
|
|
||||||
|
The provider component should be the only place that knows how state is managed.
|
||||||
|
UI components consume the context interface—they don't know if state comes from
|
||||||
|
useState, Zustand, or a server sync.
|
||||||
|
|
||||||
|
**Incorrect (UI coupled to state implementation):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ChannelComposer({ channelId }: { channelId: string }) {
|
||||||
|
// UI component knows about global state implementation
|
||||||
|
const state = useGlobalChannelState(channelId);
|
||||||
|
const { submit, updateInput } = useChannelSync(channelId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Input value={state.input} onChange={(text) => sync.updateInput(text)} />
|
||||||
|
<Composer.Submit onPress={() => sync.submit()} />
|
||||||
|
</Composer.Frame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (state management isolated in provider):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Provider handles all state management details
|
||||||
|
function ChannelProvider({
|
||||||
|
channelId,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
channelId: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { state, update, submit } = useGlobalChannel(channelId);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Composer.Provider state={state} actions={{ update, submit }} meta={{ inputRef }}>
|
||||||
|
{children}
|
||||||
|
</Composer.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI component only knows about the context interface
|
||||||
|
function ChannelComposer() {
|
||||||
|
return (
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Header />
|
||||||
|
<Composer.Input />
|
||||||
|
<Composer.Footer>
|
||||||
|
<Composer.Submit />
|
||||||
|
</Composer.Footer>
|
||||||
|
</Composer.Frame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
function Channel({ channelId }: { channelId: string }) {
|
||||||
|
return (
|
||||||
|
<ChannelProvider channelId={channelId}>
|
||||||
|
<ChannelComposer />
|
||||||
|
</ChannelProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Different providers, same UI:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Local state for ephemeral forms
|
||||||
|
function ForwardMessageProvider({ children }) {
|
||||||
|
const [state, setState] = useState(initialState);
|
||||||
|
const forwardMessage = useForwardMessage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Composer.Provider state={state} actions={{ update: setState, submit: forwardMessage }}>
|
||||||
|
{children}
|
||||||
|
</Composer.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global synced state for channels
|
||||||
|
function ChannelProvider({ channelId, children }) {
|
||||||
|
const { state, update, submit } = useGlobalChannel(channelId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Composer.Provider state={state} actions={{ update, submit }}>
|
||||||
|
{children}
|
||||||
|
</Composer.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The same `Composer.Input` component works with both providers because it only
|
||||||
|
depends on the context interface, not the implementation.
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
---
|
||||||
|
title: Lift State into Provider Components
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: enables state sharing outside component boundaries
|
||||||
|
tags: composition, state, context, providers
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lift State into Provider Components
|
||||||
|
|
||||||
|
Move state management into dedicated provider components. This allows sibling
|
||||||
|
components outside the main UI to access and modify state without prop drilling
|
||||||
|
or awkward refs.
|
||||||
|
|
||||||
|
**Incorrect (state trapped inside component):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ForwardMessageComposer() {
|
||||||
|
const [state, setState] = useState(initialState);
|
||||||
|
const forwardMessage = useForwardMessage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Composer.Frame>
|
||||||
|
<Composer.Input />
|
||||||
|
<Composer.Footer />
|
||||||
|
</Composer.Frame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Problem: How does this button access composer state?
|
||||||
|
function ForwardMessageDialog() {
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<ForwardMessageComposer />
|
||||||
|
<MessagePreview /> {/* Needs composer state */}
|
||||||
|
<DialogActions>
|
||||||
|
<CancelButton />
|
||||||
|
<ForwardButton /> {/* Needs to call submit */}
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Incorrect (useEffect to sync state up):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ForwardMessageDialog() {
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<ForwardMessageComposer onInputChange={setInput} />
|
||||||
|
<MessagePreview input={input} />
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ForwardMessageComposer({ onInputChange }) {
|
||||||
|
const [state, setState] = useState(initialState);
|
||||||
|
useEffect(() => {
|
||||||
|
onInputChange(state.input); // Sync on every change 😬
|
||||||
|
}, [state.input]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Incorrect (reading state from ref on submit):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ForwardMessageDialog() {
|
||||||
|
const stateRef = useRef(null);
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<ForwardMessageComposer stateRef={stateRef} />
|
||||||
|
<ForwardButton onPress={() => submit(stateRef.current)} />
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (state lifted to provider):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [state, setState] = useState(initialState);
|
||||||
|
const forwardMessage = useForwardMessage();
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Composer.Provider
|
||||||
|
state={state}
|
||||||
|
actions={{ update: setState, submit: forwardMessage }}
|
||||||
|
meta={{ inputRef }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Composer.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ForwardMessageDialog() {
|
||||||
|
return (
|
||||||
|
<ForwardMessageProvider>
|
||||||
|
<Dialog>
|
||||||
|
<ForwardMessageComposer />
|
||||||
|
<MessagePreview /> {/* Custom components can access state and actions */}
|
||||||
|
<DialogActions>
|
||||||
|
<CancelButton />
|
||||||
|
<ForwardButton /> {/* Custom components can access state and actions */}
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</ForwardMessageProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ForwardButton() {
|
||||||
|
const { actions } = use(Composer.Context);
|
||||||
|
return <Button onPress={actions.submit}>Forward</Button>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The ForwardButton lives outside the Composer.Frame but still has access to the
|
||||||
|
submit action because it's within the provider. Even though it's a one-off
|
||||||
|
component, it can still access the composer's state and actions from outside the
|
||||||
|
UI itself.
|
||||||
|
|
||||||
|
**Key insight:** Components that need shared state don't have to be visually
|
||||||
|
nested inside each other—they just need to be within the same provider.
|
||||||
3185
.agents/skills/vercel-react-best-practices/AGENTS.md
Normal file
3185
.agents/skills/vercel-react-best-practices/AGENTS.md
Normal file
File diff suppressed because it is too large
Load Diff
127
.agents/skills/vercel-react-best-practices/README.md
Normal file
127
.agents/skills/vercel-react-best-practices/README.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# React Best Practices
|
||||||
|
|
||||||
|
A structured repository for creating and maintaining React Best Practices optimized for agents and LLMs.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
- `rules/` - Individual rule files (one per rule)
|
||||||
|
- `_sections.md` - Section metadata (titles, impacts, descriptions)
|
||||||
|
- `_template.md` - Template for creating new rules
|
||||||
|
- `area-description.md` - Individual rule files
|
||||||
|
- `src/` - Build scripts and utilities
|
||||||
|
- `metadata.json` - Document metadata (version, organization, abstract)
|
||||||
|
- **`AGENTS.md`** - Compiled output (generated)
|
||||||
|
- **`test-cases.json`** - Test cases for LLM evaluation (generated)
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Build AGENTS.md from rules:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Validate rule files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm validate
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Extract test cases:
|
||||||
|
```bash
|
||||||
|
pnpm extract-tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating a New Rule
|
||||||
|
|
||||||
|
1. Copy `rules/_template.md` to `rules/area-description.md`
|
||||||
|
2. Choose the appropriate area prefix:
|
||||||
|
- `async-` for Eliminating Waterfalls (Section 1)
|
||||||
|
- `bundle-` for Bundle Size Optimization (Section 2)
|
||||||
|
- `server-` for Server-Side Performance (Section 3)
|
||||||
|
- `client-` for Client-Side Data Fetching (Section 4)
|
||||||
|
- `rerender-` for Re-render Optimization (Section 5)
|
||||||
|
- `rendering-` for Rendering Performance (Section 6)
|
||||||
|
- `js-` for JavaScript Performance (Section 7)
|
||||||
|
- `advanced-` for Advanced Patterns (Section 8)
|
||||||
|
3. Fill in the frontmatter and content
|
||||||
|
4. Ensure you have clear examples with explanations
|
||||||
|
5. Run `pnpm build` to regenerate AGENTS.md and test-cases.json
|
||||||
|
|
||||||
|
## Rule File Structure
|
||||||
|
|
||||||
|
Each rule file should follow this structure:
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
---
|
||||||
|
title: Rule Title Here
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: Optional description
|
||||||
|
tags: tag1, tag2, tag3
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rule Title Here
|
||||||
|
|
||||||
|
Brief explanation of the rule and why it matters.
|
||||||
|
|
||||||
|
**Incorrect (description of what's wrong):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Bad code example
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
**Correct (description of what's right):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good code example
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional explanatory text after examples.
|
||||||
|
|
||||||
|
Reference: [Link](https://example.com)
|
||||||
|
|
||||||
|
## File Naming Convention
|
||||||
|
|
||||||
|
- Files starting with `_` are special (excluded from build)
|
||||||
|
- Rule files: `area-description.md` (e.g., `async-parallel.md`)
|
||||||
|
- Section is automatically inferred from filename prefix
|
||||||
|
- Rules are sorted alphabetically by title within each section
|
||||||
|
- IDs (e.g., 1.1, 1.2) are auto-generated during build
|
||||||
|
|
||||||
|
## Impact Levels
|
||||||
|
|
||||||
|
- `CRITICAL` - Highest priority, major performance gains
|
||||||
|
- `HIGH` - Significant performance improvements
|
||||||
|
- `MEDIUM-HIGH` - Moderate-high gains
|
||||||
|
- `MEDIUM` - Moderate performance improvements
|
||||||
|
- `LOW-MEDIUM` - Low-medium gains
|
||||||
|
- `LOW` - Incremental improvements
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
- `pnpm build` - Compile rules into AGENTS.md
|
||||||
|
- `pnpm validate` - Validate all rule files
|
||||||
|
- `pnpm extract-tests` - Extract test cases for LLM evaluation
|
||||||
|
- `pnpm dev` - Build and validate
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
When adding or modifying rules:
|
||||||
|
|
||||||
|
1. Use the correct filename prefix for your section
|
||||||
|
2. Follow the `_template.md` structure
|
||||||
|
3. Include clear bad/good examples with explanations
|
||||||
|
4. Add appropriate tags
|
||||||
|
5. Run `pnpm build` to regenerate AGENTS.md and test-cases.json
|
||||||
|
6. Rules are automatically sorted by title - no need to manage numbers!
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
Originally created by [@shuding](https://x.com/shuding) at [Vercel](https://vercel.com).
|
||||||
143
.agents/skills/vercel-react-best-practices/SKILL.md
Normal file
143
.agents/skills/vercel-react-best-practices/SKILL.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
---
|
||||||
|
name: vercel-react-best-practices
|
||||||
|
description: React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: vercel
|
||||||
|
version: "1.0.0"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vercel React Best Practices
|
||||||
|
|
||||||
|
Comprehensive performance optimization guide for React and Next.js applications, maintained by Vercel. Contains 62 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
Reference these guidelines when:
|
||||||
|
|
||||||
|
- Writing new React components or Next.js pages
|
||||||
|
- Implementing data fetching (client or server-side)
|
||||||
|
- Reviewing code for performance issues
|
||||||
|
- Refactoring existing React/Next.js code
|
||||||
|
- Optimizing bundle size or load times
|
||||||
|
|
||||||
|
## Rule Categories by Priority
|
||||||
|
|
||||||
|
| Priority | Category | Impact | Prefix |
|
||||||
|
| -------- | ------------------------- | ----------- | ------------ |
|
||||||
|
| 1 | Eliminating Waterfalls | CRITICAL | `async-` |
|
||||||
|
| 2 | Bundle Size Optimization | CRITICAL | `bundle-` |
|
||||||
|
| 3 | Server-Side Performance | HIGH | `server-` |
|
||||||
|
| 4 | Client-Side Data Fetching | MEDIUM-HIGH | `client-` |
|
||||||
|
| 5 | Re-render Optimization | MEDIUM | `rerender-` |
|
||||||
|
| 6 | Rendering Performance | MEDIUM | `rendering-` |
|
||||||
|
| 7 | JavaScript Performance | LOW-MEDIUM | `js-` |
|
||||||
|
| 8 | Advanced Patterns | LOW | `advanced-` |
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### 1. Eliminating Waterfalls (CRITICAL)
|
||||||
|
|
||||||
|
- `async-defer-await` - Move await into branches where actually used
|
||||||
|
- `async-parallel` - Use Promise.all() for independent operations
|
||||||
|
- `async-dependencies` - Use better-all for partial dependencies
|
||||||
|
- `async-api-routes` - Start promises early, await late in API routes
|
||||||
|
- `async-suspense-boundaries` - Use Suspense to stream content
|
||||||
|
|
||||||
|
### 2. Bundle Size Optimization (CRITICAL)
|
||||||
|
|
||||||
|
- `bundle-barrel-imports` - Import directly, avoid barrel files
|
||||||
|
- `bundle-dynamic-imports` - Use next/dynamic for heavy components
|
||||||
|
- `bundle-defer-third-party` - Load analytics/logging after hydration
|
||||||
|
- `bundle-conditional` - Load modules only when feature is activated
|
||||||
|
- `bundle-preload` - Preload on hover/focus for perceived speed
|
||||||
|
|
||||||
|
### 3. Server-Side Performance (HIGH)
|
||||||
|
|
||||||
|
- `server-auth-actions` - Authenticate server actions like API routes
|
||||||
|
- `server-cache-react` - Use React.cache() for per-request deduplication
|
||||||
|
- `server-cache-lru` - Use LRU cache for cross-request caching
|
||||||
|
- `server-dedup-props` - Avoid duplicate serialization in RSC props
|
||||||
|
- `server-hoist-static-io` - Hoist static I/O (fonts, logos) to module level
|
||||||
|
- `server-serialization` - Minimize data passed to client components
|
||||||
|
- `server-parallel-fetching` - Restructure components to parallelize fetches
|
||||||
|
- `server-after-nonblocking` - Use after() for non-blocking operations
|
||||||
|
|
||||||
|
### 4. Client-Side Data Fetching (MEDIUM-HIGH)
|
||||||
|
|
||||||
|
- `client-swr-dedup` - Use SWR for automatic request deduplication
|
||||||
|
- `client-event-listeners` - Deduplicate global event listeners
|
||||||
|
- `client-passive-event-listeners` - Use passive listeners for scroll
|
||||||
|
- `client-localstorage-schema` - Version and minimize localStorage data
|
||||||
|
|
||||||
|
### 5. Re-render Optimization (MEDIUM)
|
||||||
|
|
||||||
|
- `rerender-defer-reads` - Don't subscribe to state only used in callbacks
|
||||||
|
- `rerender-memo` - Extract expensive work into memoized components
|
||||||
|
- `rerender-memo-with-default-value` - Hoist default non-primitive props
|
||||||
|
- `rerender-dependencies` - Use primitive dependencies in effects
|
||||||
|
- `rerender-derived-state` - Subscribe to derived booleans, not raw values
|
||||||
|
- `rerender-derived-state-no-effect` - Derive state during render, not effects
|
||||||
|
- `rerender-functional-setstate` - Use functional setState for stable callbacks
|
||||||
|
- `rerender-lazy-state-init` - Pass function to useState for expensive values
|
||||||
|
- `rerender-simple-expression-in-memo` - Avoid memo for simple primitives
|
||||||
|
- `rerender-move-effect-to-event` - Put interaction logic in event handlers
|
||||||
|
- `rerender-transitions` - Use startTransition for non-urgent updates
|
||||||
|
- `rerender-use-ref-transient-values` - Use refs for transient frequent values
|
||||||
|
- `rerender-no-inline-components` - Don't define components inside components
|
||||||
|
|
||||||
|
### 6. Rendering Performance (MEDIUM)
|
||||||
|
|
||||||
|
- `rendering-animate-svg-wrapper` - Animate div wrapper, not SVG element
|
||||||
|
- `rendering-content-visibility` - Use content-visibility for long lists
|
||||||
|
- `rendering-hoist-jsx` - Extract static JSX outside components
|
||||||
|
- `rendering-svg-precision` - Reduce SVG coordinate precision
|
||||||
|
- `rendering-hydration-no-flicker` - Use inline script for client-only data
|
||||||
|
- `rendering-hydration-suppress-warning` - Suppress expected mismatches
|
||||||
|
- `rendering-activity` - Use Activity component for show/hide
|
||||||
|
- `rendering-conditional-render` - Use ternary, not && for conditionals
|
||||||
|
- `rendering-usetransition-loading` - Prefer useTransition for loading state
|
||||||
|
- `rendering-resource-hints` - Use React DOM resource hints for preloading
|
||||||
|
- `rendering-script-defer-async` - Use defer or async on script tags
|
||||||
|
|
||||||
|
### 7. JavaScript Performance (LOW-MEDIUM)
|
||||||
|
|
||||||
|
- `js-batch-dom-css` - Group CSS changes via classes or cssText
|
||||||
|
- `js-index-maps` - Build Map for repeated lookups
|
||||||
|
- `js-cache-property-access` - Cache object properties in loops
|
||||||
|
- `js-cache-function-results` - Cache function results in module-level Map
|
||||||
|
- `js-cache-storage` - Cache localStorage/sessionStorage reads
|
||||||
|
- `js-combine-iterations` - Combine multiple filter/map into one loop
|
||||||
|
- `js-length-check-first` - Check array length before expensive comparison
|
||||||
|
- `js-early-exit` - Return early from functions
|
||||||
|
- `js-hoist-regexp` - Hoist RegExp creation outside loops
|
||||||
|
- `js-min-max-loop` - Use loop for min/max instead of sort
|
||||||
|
- `js-set-map-lookups` - Use Set/Map for O(1) lookups
|
||||||
|
- `js-tosorted-immutable` - Use toSorted() for immutability
|
||||||
|
- `js-flatmap-filter` - Use flatMap to map and filter in one pass
|
||||||
|
|
||||||
|
### 8. Advanced Patterns (LOW)
|
||||||
|
|
||||||
|
- `advanced-event-handler-refs` - Store event handlers in refs
|
||||||
|
- `advanced-init-once` - Initialize app once per app load
|
||||||
|
- `advanced-use-latest` - useLatest for stable callback refs
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
Read individual rule files for detailed explanations and code examples:
|
||||||
|
|
||||||
|
```
|
||||||
|
rules/async-parallel.md
|
||||||
|
rules/bundle-barrel-imports.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Each rule file contains:
|
||||||
|
|
||||||
|
- Brief explanation of why it matters
|
||||||
|
- Incorrect code example with explanation
|
||||||
|
- Correct code example with explanation
|
||||||
|
- Additional context and references
|
||||||
|
|
||||||
|
## Full Compiled Document
|
||||||
|
|
||||||
|
For the complete guide with all rules expanded: `AGENTS.md`
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
title: Store Event Handlers in Refs
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: stable subscriptions
|
||||||
|
tags: advanced, hooks, refs, event-handlers, optimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Store Event Handlers in Refs
|
||||||
|
|
||||||
|
Store callbacks in refs when used in effects that shouldn't re-subscribe on callback changes.
|
||||||
|
|
||||||
|
**Incorrect (re-subscribes on every render):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function useWindowEvent(event: string, handler: (e) => void) {
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener(event, handler);
|
||||||
|
return () => window.removeEventListener(event, handler);
|
||||||
|
}, [event, handler]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (stable subscription):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function useWindowEvent(event: string, handler: (e) => void) {
|
||||||
|
const handlerRef = useRef(handler);
|
||||||
|
useEffect(() => {
|
||||||
|
handlerRef.current = handler;
|
||||||
|
}, [handler]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = (e) => handlerRef.current(e);
|
||||||
|
window.addEventListener(event, listener);
|
||||||
|
return () => window.removeEventListener(event, listener);
|
||||||
|
}, [event]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative: use `useEffectEvent` if you're on latest React:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffectEvent } from "react";
|
||||||
|
|
||||||
|
function useWindowEvent(event: string, handler: (e) => void) {
|
||||||
|
const onEvent = useEffectEvent(handler);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener(event, onEvent);
|
||||||
|
return () => window.removeEventListener(event, onEvent);
|
||||||
|
}, [event]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`useEffectEvent` provides a cleaner API for the same pattern: it creates a stable function reference that always calls the latest version of the handler.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
title: Initialize App Once, Not Per Mount
|
||||||
|
impact: LOW-MEDIUM
|
||||||
|
impactDescription: avoids duplicate init in development
|
||||||
|
tags: initialization, useEffect, app-startup, side-effects
|
||||||
|
---
|
||||||
|
|
||||||
|
## Initialize App Once, Not Per Mount
|
||||||
|
|
||||||
|
Do not put app-wide initialization that must run once per app load inside `useEffect([])` of a component. Components can remount and effects will re-run. Use a module-level guard or top-level init in the entry module instead.
|
||||||
|
|
||||||
|
**Incorrect (runs twice in dev, re-runs on remount):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Comp() {
|
||||||
|
useEffect(() => {
|
||||||
|
loadFromStorage();
|
||||||
|
checkAuthToken();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (once per app load):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
let didInit = false;
|
||||||
|
|
||||||
|
function Comp() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (didInit) return;
|
||||||
|
didInit = true;
|
||||||
|
loadFromStorage();
|
||||||
|
checkAuthToken();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: [Initializing the application](https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
title: useEffectEvent for Stable Callback Refs
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: prevents effect re-runs
|
||||||
|
tags: advanced, hooks, useEffectEvent, refs, optimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## useEffectEvent for Stable Callback Refs
|
||||||
|
|
||||||
|
Access latest values in callbacks without adding them to dependency arrays. Prevents effect re-runs while avoiding stale closures.
|
||||||
|
|
||||||
|
**Incorrect (effect re-runs on every callback change):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => onSearch(query), 300);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [query, onSearch]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (using React's useEffectEvent):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffectEvent } from "react";
|
||||||
|
|
||||||
|
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const onSearchEvent = useEffectEvent(onSearch);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => onSearchEvent(query), 300);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [query]);
|
||||||
|
}
|
||||||
|
```
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user