create-edhor-stack 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/STACK.md +1086 -0
- package/dist/index.js +3181 -0
- package/package.json +44 -0
- package/templates/apps/api-elysia/package.json +21 -0
- package/templates/apps/api-elysia/src/index.ts +59 -0
- package/templates/apps/api-elysia/src/lib/eden.ts +25 -0
- package/templates/apps/api-elysia/src/lib/env.ts +18 -0
- package/templates/apps/api-elysia/src/routes/health.ts +13 -0
- package/templates/apps/api-elysia/src/routes/users.ts +117 -0
- package/templates/apps/api-elysia/tsconfig.json +15 -0
- package/templates/apps/api-hono/package.json +20 -0
- package/templates/apps/api-hono/src/index.ts +66 -0
- package/templates/apps/api-hono/src/lib/env.ts +18 -0
- package/templates/apps/api-hono/src/routes/health.ts +20 -0
- package/templates/apps/api-hono/src/routes/users.ts +110 -0
- package/templates/apps/api-hono/tsconfig.json +15 -0
- package/templates/apps/mobile/.env.example +9 -0
- package/templates/apps/mobile/app/_layout.tsx +16 -0
- package/templates/apps/mobile/app/index.tsx +39 -0
- package/templates/apps/mobile/app.json +37 -0
- package/templates/apps/mobile/assets/adaptive-icon.png +0 -0
- package/templates/apps/mobile/assets/favicon.png +0 -0
- package/templates/apps/mobile/assets/icon.png +0 -0
- package/templates/apps/mobile/assets/splash-icon.png +0 -0
- package/templates/apps/mobile/package.json +39 -0
- package/templates/apps/mobile/src/api/client.ts +51 -0
- package/templates/apps/mobile/src/api/index.ts +3 -0
- package/templates/apps/mobile/src/api/queries.ts +24 -0
- package/templates/apps/mobile/src/api/schemas.ts +32 -0
- package/templates/apps/mobile/src/lib/env.ts +40 -0
- package/templates/apps/mobile/src/lib/query-client.ts +28 -0
- package/templates/apps/mobile/src/lib/result.ts +45 -0
- package/templates/apps/mobile/src/lib/store.ts +63 -0
- package/templates/apps/mobile/tsconfig.json +10 -0
- package/templates/apps/web/.env.example +11 -0
- package/templates/apps/web/package.json +29 -0
- package/templates/apps/web/src/lib/env.ts +52 -0
- package/templates/apps/web/src/lib/queries.ts +27 -0
- package/templates/apps/web/src/lib/query-client.ts +11 -0
- package/templates/apps/web/src/router.tsx +17 -0
- package/templates/apps/web/src/routes/__root.tsx +32 -0
- package/templates/apps/web/src/routes/index.tsx +16 -0
- package/templates/apps/web/src/styles.css +26 -0
- package/templates/apps/web/tsconfig.json +10 -0
- package/templates/apps/web/vite.config.ts +21 -0
- package/templates/base/.claude/settings.json +33 -0
- package/templates/base/.claude/skills/add-api-endpoint.md +137 -0
- package/templates/base/.claude/skills/add-component.md +79 -0
- package/templates/base/.claude/skills/add-route.md +134 -0
- package/templates/base/.claude/skills/add-store.md +158 -0
- package/templates/base/.husky/pre-commit +1 -0
- package/templates/base/.lintstagedrc +4 -0
- package/templates/base/.node-version +1 -0
- package/templates/base/AGENTS.md +135 -0
- package/templates/base/CLAUDE.md.hbs +139 -0
- package/templates/base/Dockerfile +32 -0
- package/templates/base/biome.json +52 -0
- package/templates/base/fly.toml.hbs +20 -0
- package/templates/base/gitignore +36 -0
- package/templates/base/package.json.hbs +22 -0
- package/templates/base/tsconfig.json +14 -0
- package/templates/base/turbo.json +22 -0
- package/templates/packages/shared/package.json +17 -0
- package/templates/packages/shared/src/index.ts +4 -0
- package/templates/packages/shared/src/schemas.ts +50 -0
- package/templates/packages/shared/src/types.ts +47 -0
- package/templates/packages/shared/src/utils.ts +87 -0
- package/templates/packages/shared/tsconfig.json +14 -0
- package/templates/packages/stripe/package.json +18 -0
- package/templates/packages/stripe/src/client.ts +110 -0
- package/templates/packages/stripe/src/index.ts +3 -0
- package/templates/packages/stripe/src/schemas.ts +65 -0
- package/templates/packages/stripe/src/webhooks.ts +91 -0
- package/templates/packages/stripe/tsconfig.json +14 -0
- package/templates/packages/ui/components.json +19 -0
- package/templates/packages/ui/package.json +29 -0
- package/templates/packages/ui/src/components/button.tsx +58 -0
- package/templates/packages/ui/src/index.ts +5 -0
- package/templates/packages/ui/src/lib/utils.ts +6 -0
- package/templates/packages/ui/src/styles.css +120 -0
- package/templates/packages/ui/tsconfig.json +10 -0
package/STACK.md
ADDED
|
@@ -0,0 +1,1086 @@
|
|
|
1
|
+
# STACK.md - Edhor Stack Best Practices
|
|
2
|
+
|
|
3
|
+
> This document captures the actual patterns used in Edhor production applications. It serves as context for AI assistants and developers working on projects scaffolded with create-edhor-stack.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Table of Contents
|
|
8
|
+
|
|
9
|
+
1. [Core Stack](#core-stack)
|
|
10
|
+
2. [Environment Variables](#environment-variables)
|
|
11
|
+
3. [Expo Mobile Patterns](#expo-mobile-patterns)
|
|
12
|
+
4. [TanStack Start Web Patterns](#tanstack-start-web-patterns)
|
|
13
|
+
5. [Styling Patterns](#styling-patterns)
|
|
14
|
+
6. [Database Options](#database-options)
|
|
15
|
+
7. [Authentication](#authentication)
|
|
16
|
+
8. [Code Organization](#code-organization)
|
|
17
|
+
9. [UI/Accessibility Guidelines](#uiaccessibility-guidelines)
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Core Stack
|
|
22
|
+
|
|
23
|
+
### Always Included
|
|
24
|
+
|
|
25
|
+
| Tool | Purpose | Version |
|
|
26
|
+
|------|---------|---------|
|
|
27
|
+
| Bun | Package manager & runtime | 1.3+ |
|
|
28
|
+
| Turborepo | Monorepo build orchestration | 2.5+ |
|
|
29
|
+
| TypeScript | Type safety (strict mode) | 5.8+ |
|
|
30
|
+
| Biome | Linting & formatting (replaces ESLint/Prettier) | 2.3+ |
|
|
31
|
+
| Husky | Git hooks | 9.1+ |
|
|
32
|
+
| TanStack Query | Server state management | 5.x |
|
|
33
|
+
| t3-env | Type-safe environment variables | 0.12+ |
|
|
34
|
+
|
|
35
|
+
### Biome Configuration
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"linter": {
|
|
40
|
+
"rules": {
|
|
41
|
+
"correctness": {
|
|
42
|
+
"noUnusedImports": "error",
|
|
43
|
+
"noUnusedVariables": "warn",
|
|
44
|
+
"useHookAtTopLevel": "error"
|
|
45
|
+
},
|
|
46
|
+
"style": {
|
|
47
|
+
"useImportType": "error"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"formatter": {
|
|
52
|
+
"indentStyle": "space",
|
|
53
|
+
"indentWidth": 2,
|
|
54
|
+
"lineWidth": 100
|
|
55
|
+
},
|
|
56
|
+
"javascript": {
|
|
57
|
+
"formatter": {
|
|
58
|
+
"quoteStyle": "single",
|
|
59
|
+
"trailingCommas": "es5",
|
|
60
|
+
"semicolons": "always"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Environment Variables
|
|
69
|
+
|
|
70
|
+
Use [t3-env](https://env.t3.gg/) for type-safe environment variables with Zod validation.
|
|
71
|
+
|
|
72
|
+
### Web (TanStack Start)
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
// src/lib/env.ts
|
|
76
|
+
import { createEnv } from '@t3-oss/env-core';
|
|
77
|
+
import { z } from 'zod';
|
|
78
|
+
|
|
79
|
+
export const env = createEnv({
|
|
80
|
+
server: {
|
|
81
|
+
DATABASE_URL: z.string().url(),
|
|
82
|
+
API_SECRET: z.string().min(32),
|
|
83
|
+
},
|
|
84
|
+
clientPrefix: 'VITE_',
|
|
85
|
+
client: {
|
|
86
|
+
VITE_APP_URL: z.string().url(),
|
|
87
|
+
VITE_PUBLIC_API_URL: z.string().url(),
|
|
88
|
+
},
|
|
89
|
+
runtimeEnv: {
|
|
90
|
+
DATABASE_URL: process.env.DATABASE_URL,
|
|
91
|
+
API_SECRET: process.env.API_SECRET,
|
|
92
|
+
VITE_APP_URL: process.env.VITE_APP_URL,
|
|
93
|
+
VITE_PUBLIC_API_URL: process.env.VITE_PUBLIC_API_URL,
|
|
94
|
+
},
|
|
95
|
+
emptyStringAsUndefined: true,
|
|
96
|
+
});
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Mobile (Expo)
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
// src/lib/env.ts
|
|
103
|
+
import { createEnv } from '@t3-oss/env-core';
|
|
104
|
+
import { z } from 'zod';
|
|
105
|
+
|
|
106
|
+
export const env = createEnv({
|
|
107
|
+
clientPrefix: 'EXPO_PUBLIC_',
|
|
108
|
+
client: {
|
|
109
|
+
EXPO_PUBLIC_API_URL: z.string().url(),
|
|
110
|
+
EXPO_PUBLIC_SENTRY_DSN: z.string().url().optional(),
|
|
111
|
+
},
|
|
112
|
+
runtimeEnv: {
|
|
113
|
+
EXPO_PUBLIC_API_URL: process.env.EXPO_PUBLIC_API_URL,
|
|
114
|
+
EXPO_PUBLIC_SENTRY_DSN: process.env.EXPO_PUBLIC_SENTRY_DSN,
|
|
115
|
+
},
|
|
116
|
+
emptyStringAsUndefined: true,
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Usage
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
// Always import from env.ts, never use process.env directly
|
|
124
|
+
import { env } from '@/lib/env';
|
|
125
|
+
|
|
126
|
+
// Type-safe access with autocomplete
|
|
127
|
+
const apiUrl = env.EXPO_PUBLIC_API_URL;
|
|
128
|
+
|
|
129
|
+
// Server variables throw if accessed on client
|
|
130
|
+
const secret = env.API_SECRET; // Error on client!
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### .env Files
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
# .env.local (never commit)
|
|
137
|
+
DATABASE_URL=postgresql://...
|
|
138
|
+
API_SECRET=your-secret-key
|
|
139
|
+
|
|
140
|
+
# Client variables (prefixed)
|
|
141
|
+
VITE_APP_URL=http://localhost:3000 # Web
|
|
142
|
+
EXPO_PUBLIC_API_URL=https://api.com # Mobile
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
**Key rules:**
|
|
146
|
+
- Never use `process.env` directly - always use `env` object
|
|
147
|
+
- Server variables throw if accessed on client
|
|
148
|
+
- Client variables must be prefixed (`VITE_` or `EXPO_PUBLIC_`)
|
|
149
|
+
- All variables must be in `runtimeEnv` for bundler compatibility
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Expo Mobile Patterns
|
|
154
|
+
|
|
155
|
+
### State Management: Zustand with Persistence
|
|
156
|
+
|
|
157
|
+
**Multiple specialized stores, not one giant store:**
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
// lib/store.ts
|
|
161
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
162
|
+
import { create } from 'zustand';
|
|
163
|
+
import { persist, createJSONStorage } from 'zustand/middleware';
|
|
164
|
+
|
|
165
|
+
// App settings store
|
|
166
|
+
interface AppState {
|
|
167
|
+
fontSize: 'small' | 'medium' | 'large';
|
|
168
|
+
theme: 'light' | 'dark' | 'system';
|
|
169
|
+
setFontSize: (size: AppState['fontSize']) => void;
|
|
170
|
+
setTheme: (theme: AppState['theme']) => void;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export const useAppStore = create<AppState>()(
|
|
174
|
+
persist(
|
|
175
|
+
(set) => ({
|
|
176
|
+
fontSize: 'medium',
|
|
177
|
+
theme: 'system',
|
|
178
|
+
setFontSize: (fontSize) => set({ fontSize }),
|
|
179
|
+
setTheme: (theme) => set({ theme }),
|
|
180
|
+
}),
|
|
181
|
+
{
|
|
182
|
+
name: 'app-storage',
|
|
183
|
+
storage: createJSONStorage(() => AsyncStorage),
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
// Search store (separate concern)
|
|
189
|
+
interface SearchState {
|
|
190
|
+
query: string;
|
|
191
|
+
recentSearches: string[];
|
|
192
|
+
setQuery: (query: string) => void;
|
|
193
|
+
addRecentSearch: (search: string) => void;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export const useSearchStore = create<SearchState>()(
|
|
197
|
+
persist(
|
|
198
|
+
(set) => ({
|
|
199
|
+
query: '',
|
|
200
|
+
recentSearches: [],
|
|
201
|
+
setQuery: (query) => set({ query }),
|
|
202
|
+
addRecentSearch: (search) =>
|
|
203
|
+
set((state) => ({
|
|
204
|
+
recentSearches: [search, ...state.recentSearches.filter((s) => s !== search)].slice(0, 10),
|
|
205
|
+
})),
|
|
206
|
+
}),
|
|
207
|
+
{
|
|
208
|
+
name: 'search-storage',
|
|
209
|
+
storage: createJSONStorage(() => AsyncStorage),
|
|
210
|
+
}
|
|
211
|
+
)
|
|
212
|
+
);
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**Always use selectors to prevent re-renders:**
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
// Good - only re-renders when fontSize changes
|
|
219
|
+
const fontSize = useAppStore((state) => state.fontSize);
|
|
220
|
+
|
|
221
|
+
// Bad - re-renders on ANY store change
|
|
222
|
+
const { fontSize } = useAppStore();
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### API Layer: Zod Validation with fetchValidated
|
|
226
|
+
|
|
227
|
+
**Every API call validates responses with Zod:**
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
// api/client.ts
|
|
231
|
+
import { z } from 'zod';
|
|
232
|
+
|
|
233
|
+
export async function fetchValidated<T>(
|
|
234
|
+
url: string,
|
|
235
|
+
schema: z.ZodType<T>,
|
|
236
|
+
options?: RequestInit
|
|
237
|
+
): Promise<T> {
|
|
238
|
+
const response = await fetch(url, options);
|
|
239
|
+
|
|
240
|
+
if (!response.ok) {
|
|
241
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const data = await response.json();
|
|
245
|
+
return schema.parse(data);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// api/schemas.ts
|
|
249
|
+
export const ArticleSchema = z.object({
|
|
250
|
+
id: z.string(),
|
|
251
|
+
title: z.string(),
|
|
252
|
+
content: z.string(),
|
|
253
|
+
publishedAt: z.string().datetime(),
|
|
254
|
+
author: z.object({
|
|
255
|
+
name: z.string(),
|
|
256
|
+
avatar: z.string().url().optional(),
|
|
257
|
+
}),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
export const ArticlesResponseSchema = z.object({
|
|
261
|
+
articles: z.array(ArticleSchema),
|
|
262
|
+
nextCursor: z.string().nullable(),
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
export type Article = z.infer<typeof ArticleSchema>;
|
|
266
|
+
|
|
267
|
+
// api/queries.ts
|
|
268
|
+
import { queryOptions } from '@tanstack/react-query';
|
|
269
|
+
|
|
270
|
+
export const articlesQueryOptions = (cursor?: string) =>
|
|
271
|
+
queryOptions({
|
|
272
|
+
queryKey: ['articles', { cursor }],
|
|
273
|
+
queryFn: () =>
|
|
274
|
+
fetchValidated(
|
|
275
|
+
`https://api.example.com/articles?cursor=${cursor ?? ''}`,
|
|
276
|
+
ArticlesResponseSchema
|
|
277
|
+
),
|
|
278
|
+
});
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### TanStack Query: Offline-First Configuration
|
|
282
|
+
|
|
283
|
+
```typescript
|
|
284
|
+
// lib/query-client.ts
|
|
285
|
+
import { QueryClient } from '@tanstack/react-query';
|
|
286
|
+
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
|
|
287
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
288
|
+
|
|
289
|
+
export const queryClient = new QueryClient({
|
|
290
|
+
defaultOptions: {
|
|
291
|
+
queries: {
|
|
292
|
+
// Keep cached data for 7 days (offline support)
|
|
293
|
+
gcTime: 1000 * 60 * 60 * 24 * 7,
|
|
294
|
+
// Data considered fresh for 5 minutes
|
|
295
|
+
staleTime: 1000 * 60 * 5,
|
|
296
|
+
// Try cache first, then network
|
|
297
|
+
networkMode: 'offlineFirst',
|
|
298
|
+
// Retry with exponential backoff
|
|
299
|
+
retry: 3,
|
|
300
|
+
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
|
301
|
+
},
|
|
302
|
+
mutations: {
|
|
303
|
+
networkMode: 'offlineFirst',
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Persist to AsyncStorage for true offline support
|
|
309
|
+
export const persister = createAsyncStoragePersister({
|
|
310
|
+
storage: AsyncStorage,
|
|
311
|
+
key: 'REACT_QUERY_OFFLINE_CACHE',
|
|
312
|
+
});
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
**App entry with persistence:**
|
|
316
|
+
|
|
317
|
+
```typescript
|
|
318
|
+
// app/_layout.tsx
|
|
319
|
+
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
|
|
320
|
+
import { queryClient, persister } from '@/lib/query-client';
|
|
321
|
+
|
|
322
|
+
export default function RootLayout() {
|
|
323
|
+
return (
|
|
324
|
+
<PersistQueryClientProvider client={queryClient} persistOptions={{ persister }}>
|
|
325
|
+
<Stack />
|
|
326
|
+
</PersistQueryClientProvider>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### Error Handling: Result Pattern
|
|
332
|
+
|
|
333
|
+
**Explicit error handling without try-catch everywhere:**
|
|
334
|
+
|
|
335
|
+
```typescript
|
|
336
|
+
// lib/result.ts
|
|
337
|
+
export type Result<T, E = Error> =
|
|
338
|
+
| { ok: true; value: T }
|
|
339
|
+
| { ok: false; error: E };
|
|
340
|
+
|
|
341
|
+
export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
|
|
342
|
+
export const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
|
|
343
|
+
|
|
344
|
+
// Usage in API calls
|
|
345
|
+
export async function fetchArticle(id: string): Promise<Result<Article, string>> {
|
|
346
|
+
try {
|
|
347
|
+
const article = await fetchValidated(`/api/articles/${id}`, ArticleSchema);
|
|
348
|
+
return ok(article);
|
|
349
|
+
} catch (e) {
|
|
350
|
+
if (e instanceof z.ZodError) {
|
|
351
|
+
return err('Invalid response format');
|
|
352
|
+
}
|
|
353
|
+
return err(e instanceof Error ? e.message : 'Unknown error');
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Usage in component
|
|
358
|
+
const result = await fetchArticle(id);
|
|
359
|
+
if (!result.ok) {
|
|
360
|
+
showToast(result.error);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const article = result.value;
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
### Custom Hooks
|
|
367
|
+
|
|
368
|
+
```typescript
|
|
369
|
+
// hooks/useNetworkStatus.ts
|
|
370
|
+
import NetInfo from '@react-native-community/netinfo';
|
|
371
|
+
import { useEffect, useState } from 'react';
|
|
372
|
+
|
|
373
|
+
export function useNetworkStatus() {
|
|
374
|
+
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
|
375
|
+
|
|
376
|
+
useEffect(() => {
|
|
377
|
+
return NetInfo.addEventListener((state) => {
|
|
378
|
+
setIsConnected(state.isConnected);
|
|
379
|
+
});
|
|
380
|
+
}, []);
|
|
381
|
+
|
|
382
|
+
return isConnected;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// hooks/useDebouncedState.ts
|
|
386
|
+
import { useState, useEffect } from 'react';
|
|
387
|
+
|
|
388
|
+
export function useDebouncedState<T>(initialValue: T, delay: number = 300) {
|
|
389
|
+
const [value, setValue] = useState(initialValue);
|
|
390
|
+
const [debouncedValue, setDebouncedValue] = useState(initialValue);
|
|
391
|
+
|
|
392
|
+
useEffect(() => {
|
|
393
|
+
const timer = setTimeout(() => setDebouncedValue(value), delay);
|
|
394
|
+
return () => clearTimeout(timer);
|
|
395
|
+
}, [value, delay]);
|
|
396
|
+
|
|
397
|
+
return [debouncedValue, setValue, value] as const;
|
|
398
|
+
}
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
### Virtualized Lists: FlashList
|
|
402
|
+
|
|
403
|
+
```typescript
|
|
404
|
+
import { FlashList } from '@shopify/flash-list';
|
|
405
|
+
|
|
406
|
+
function ArticleList({ articles }: { articles: Article[] }) {
|
|
407
|
+
return (
|
|
408
|
+
<FlashList
|
|
409
|
+
data={articles}
|
|
410
|
+
renderItem={({ item }) => <ArticleCard article={item} />}
|
|
411
|
+
estimatedItemSize={120}
|
|
412
|
+
keyExtractor={(item) => item.id}
|
|
413
|
+
/>
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
### Navigation: Expo Router
|
|
419
|
+
|
|
420
|
+
```typescript
|
|
421
|
+
// app/(tabs)/_layout.tsx
|
|
422
|
+
import { Tabs } from 'expo-router';
|
|
423
|
+
import { Home, Search, Settings } from 'lucide-react-native';
|
|
424
|
+
|
|
425
|
+
export default function TabLayout() {
|
|
426
|
+
return (
|
|
427
|
+
<Tabs screenOptions={{ headerShown: false }}>
|
|
428
|
+
<Tabs.Screen
|
|
429
|
+
name="index"
|
|
430
|
+
options={{
|
|
431
|
+
title: 'Home',
|
|
432
|
+
tabBarIcon: ({ color, size }) => <Home color={color} size={size} />,
|
|
433
|
+
}}
|
|
434
|
+
/>
|
|
435
|
+
<Tabs.Screen
|
|
436
|
+
name="search"
|
|
437
|
+
options={{
|
|
438
|
+
title: 'Search',
|
|
439
|
+
tabBarIcon: ({ color, size }) => <Search color={color} size={size} />,
|
|
440
|
+
}}
|
|
441
|
+
/>
|
|
442
|
+
<Tabs.Screen
|
|
443
|
+
name="settings"
|
|
444
|
+
options={{
|
|
445
|
+
title: 'Settings',
|
|
446
|
+
tabBarIcon: ({ color, size }) => <Settings color={color} size={size} />,
|
|
447
|
+
}}
|
|
448
|
+
/>
|
|
449
|
+
</Tabs>
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// app/article/[slug].tsx - Dynamic route
|
|
454
|
+
import { useLocalSearchParams } from 'expo-router';
|
|
455
|
+
|
|
456
|
+
export default function ArticleScreen() {
|
|
457
|
+
const { slug } = useLocalSearchParams<{ slug: string }>();
|
|
458
|
+
const { data: article } = useQuery(articleQueryOptions(slug));
|
|
459
|
+
// ...
|
|
460
|
+
}
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
---
|
|
464
|
+
|
|
465
|
+
## TanStack Start Web Patterns
|
|
466
|
+
|
|
467
|
+
> **Note**: TanStack Start uses Vite 6+ as its build tool. Configuration lives in `vite.config.ts` using the `@tanstack/react-start/plugin/vite` plugin.
|
|
468
|
+
|
|
469
|
+
### Vite Configuration
|
|
470
|
+
|
|
471
|
+
```typescript
|
|
472
|
+
// vite.config.ts
|
|
473
|
+
import { tanstackStart } from '@tanstack/react-start/plugin/vite';
|
|
474
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
475
|
+
import viteReact from '@vitejs/plugin-react';
|
|
476
|
+
import { defineConfig } from 'vite';
|
|
477
|
+
import tsConfigPaths from 'vite-tsconfig-paths';
|
|
478
|
+
|
|
479
|
+
export default defineConfig({
|
|
480
|
+
server: {
|
|
481
|
+
port: 3000,
|
|
482
|
+
},
|
|
483
|
+
plugins: [
|
|
484
|
+
tailwindcss(),
|
|
485
|
+
tsConfigPaths({
|
|
486
|
+
projects: ['./tsconfig.json'],
|
|
487
|
+
}),
|
|
488
|
+
tanstackStart({
|
|
489
|
+
srcDirectory: 'src',
|
|
490
|
+
}),
|
|
491
|
+
viteReact(),
|
|
492
|
+
],
|
|
493
|
+
});
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
### Router Configuration
|
|
497
|
+
|
|
498
|
+
```typescript
|
|
499
|
+
// src/router.tsx
|
|
500
|
+
import { createRouter } from '@tanstack/react-router';
|
|
501
|
+
import { routeTree } from './routeTree.gen';
|
|
502
|
+
|
|
503
|
+
export function getRouter() {
|
|
504
|
+
const router = createRouter({
|
|
505
|
+
routeTree,
|
|
506
|
+
defaultPreload: 'intent',
|
|
507
|
+
scrollRestoration: true,
|
|
508
|
+
});
|
|
509
|
+
return router;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
declare module '@tanstack/react-router' {
|
|
513
|
+
interface Register {
|
|
514
|
+
router: ReturnType<typeof getRouter>;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
### Data Fetching: TanStack Query
|
|
520
|
+
|
|
521
|
+
**Query options factories for consistency:**
|
|
522
|
+
|
|
523
|
+
```typescript
|
|
524
|
+
// lib/queries.ts
|
|
525
|
+
import { queryOptions } from '@tanstack/react-query';
|
|
526
|
+
|
|
527
|
+
export const projectsQueryOptions = queryOptions({
|
|
528
|
+
queryKey: ['projects'],
|
|
529
|
+
queryFn: async () => {
|
|
530
|
+
const response = await fetch('/api/projects');
|
|
531
|
+
return response.json();
|
|
532
|
+
},
|
|
533
|
+
staleTime: 1000 * 60 * 5,
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
export const projectQueryOptions = (id: string) =>
|
|
537
|
+
queryOptions({
|
|
538
|
+
queryKey: ['projects', id],
|
|
539
|
+
queryFn: async () => {
|
|
540
|
+
const response = await fetch(`/api/projects/${id}`);
|
|
541
|
+
return response.json();
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
**Route loaders with React Query:**
|
|
547
|
+
|
|
548
|
+
```typescript
|
|
549
|
+
// routes/projects.tsx
|
|
550
|
+
import { createFileRoute } from '@tanstack/react-router';
|
|
551
|
+
import { projectsQueryOptions } from '@/lib/queries';
|
|
552
|
+
|
|
553
|
+
export const Route = createFileRoute('/projects')({
|
|
554
|
+
loader: ({ context }) => context.queryClient.ensureQueryData(projectsQueryOptions),
|
|
555
|
+
component: ProjectsPage,
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
function ProjectsPage() {
|
|
559
|
+
const { data: projects } = useSuspenseQuery(projectsQueryOptions);
|
|
560
|
+
return <ProjectList projects={projects} />;
|
|
561
|
+
}
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
### Real-time Data: Convex
|
|
565
|
+
|
|
566
|
+
```typescript
|
|
567
|
+
// convex/messages.ts
|
|
568
|
+
import { query, mutation } from './_generated/server';
|
|
569
|
+
import { v } from 'convex/values';
|
|
570
|
+
|
|
571
|
+
export const list = query({
|
|
572
|
+
args: { channelId: v.id('channels') },
|
|
573
|
+
handler: async (ctx, args) => {
|
|
574
|
+
return await ctx.db
|
|
575
|
+
.query('messages')
|
|
576
|
+
.withIndex('by_channel', (q) => q.eq('channelId', args.channelId))
|
|
577
|
+
.order('desc')
|
|
578
|
+
.take(50);
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
export const send = mutation({
|
|
583
|
+
args: {
|
|
584
|
+
channelId: v.id('channels'),
|
|
585
|
+
content: v.string(),
|
|
586
|
+
},
|
|
587
|
+
handler: async (ctx, args) => {
|
|
588
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
589
|
+
if (!identity) throw new Error('Unauthorized');
|
|
590
|
+
|
|
591
|
+
return await ctx.db.insert('messages', {
|
|
592
|
+
channelId: args.channelId,
|
|
593
|
+
content: args.content,
|
|
594
|
+
authorId: identity.subject,
|
|
595
|
+
createdAt: Date.now(),
|
|
596
|
+
});
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
```typescript
|
|
602
|
+
// Component usage
|
|
603
|
+
import { useQuery, useMutation } from 'convex/react';
|
|
604
|
+
import { api } from '@/convex/_generated/api';
|
|
605
|
+
|
|
606
|
+
function Chat({ channelId }: { channelId: Id<'channels'> }) {
|
|
607
|
+
const messages = useQuery(api.messages.list, { channelId });
|
|
608
|
+
const sendMessage = useMutation(api.messages.send);
|
|
609
|
+
|
|
610
|
+
// messages automatically updates when database changes
|
|
611
|
+
}
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
### Tables: TanStack Table
|
|
615
|
+
|
|
616
|
+
```typescript
|
|
617
|
+
// components/data-table.tsx
|
|
618
|
+
import {
|
|
619
|
+
useReactTable,
|
|
620
|
+
getCoreRowModel,
|
|
621
|
+
getSortedRowModel,
|
|
622
|
+
getFilteredRowModel,
|
|
623
|
+
getPaginationRowModel,
|
|
624
|
+
flexRender,
|
|
625
|
+
type ColumnDef,
|
|
626
|
+
type SortingState,
|
|
627
|
+
type ColumnFiltersState,
|
|
628
|
+
} from '@tanstack/react-table';
|
|
629
|
+
|
|
630
|
+
interface DataTableProps<T> {
|
|
631
|
+
data: T[];
|
|
632
|
+
columns: ColumnDef<T>[];
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
export function DataTable<T>({ data, columns }: DataTableProps<T>) {
|
|
636
|
+
const [sorting, setSorting] = useState<SortingState>([]);
|
|
637
|
+
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
|
638
|
+
|
|
639
|
+
const table = useReactTable({
|
|
640
|
+
data,
|
|
641
|
+
columns,
|
|
642
|
+
getCoreRowModel: getCoreRowModel(),
|
|
643
|
+
getSortedRowModel: getSortedRowModel(),
|
|
644
|
+
getFilteredRowModel: getFilteredRowModel(),
|
|
645
|
+
getPaginationRowModel: getPaginationRowModel(),
|
|
646
|
+
onSortingChange: setSorting,
|
|
647
|
+
onColumnFiltersChange: setColumnFilters,
|
|
648
|
+
state: { sorting, columnFilters },
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
return (
|
|
652
|
+
<div>
|
|
653
|
+
{/* Filter input */}
|
|
654
|
+
<input
|
|
655
|
+
placeholder="Filter..."
|
|
656
|
+
value={(table.getColumn('name')?.getFilterValue() as string) ?? ''}
|
|
657
|
+
onChange={(e) => table.getColumn('name')?.setFilterValue(e.target.value)}
|
|
658
|
+
/>
|
|
659
|
+
|
|
660
|
+
{/* Table */}
|
|
661
|
+
<table>
|
|
662
|
+
<thead>
|
|
663
|
+
{table.getHeaderGroups().map((headerGroup) => (
|
|
664
|
+
<tr key={headerGroup.id}>
|
|
665
|
+
{headerGroup.headers.map((header) => (
|
|
666
|
+
<th key={header.id} onClick={header.column.getToggleSortingHandler()}>
|
|
667
|
+
{flexRender(header.column.columnDef.header, header.getContext())}
|
|
668
|
+
{{ asc: ' ↑', desc: ' ↓' }[header.column.getIsSorted() as string] ?? null}
|
|
669
|
+
</th>
|
|
670
|
+
))}
|
|
671
|
+
</tr>
|
|
672
|
+
))}
|
|
673
|
+
</thead>
|
|
674
|
+
<tbody>
|
|
675
|
+
{table.getRowModel().rows.map((row) => (
|
|
676
|
+
<tr key={row.id}>
|
|
677
|
+
{row.getVisibleCells().map((cell) => (
|
|
678
|
+
<td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
|
|
679
|
+
))}
|
|
680
|
+
</tr>
|
|
681
|
+
))}
|
|
682
|
+
</tbody>
|
|
683
|
+
</table>
|
|
684
|
+
|
|
685
|
+
{/* Pagination */}
|
|
686
|
+
<div>
|
|
687
|
+
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
|
|
688
|
+
Previous
|
|
689
|
+
</button>
|
|
690
|
+
<span>
|
|
691
|
+
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
|
692
|
+
</span>
|
|
693
|
+
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
|
|
694
|
+
Next
|
|
695
|
+
</button>
|
|
696
|
+
</div>
|
|
697
|
+
</div>
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
### Server Functions
|
|
703
|
+
|
|
704
|
+
```typescript
|
|
705
|
+
// routes/api/projects.ts
|
|
706
|
+
import { createServerFn } from '@tanstack/start';
|
|
707
|
+
import { db } from '@/lib/db';
|
|
708
|
+
import { projects } from '@/lib/schema';
|
|
709
|
+
|
|
710
|
+
export const getProjects = createServerFn('GET', async () => {
|
|
711
|
+
return await db.select().from(projects);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
export const createProject = createServerFn('POST', async (data: { name: string; description?: string }) => {
|
|
715
|
+
const [project] = await db.insert(projects).values(data).returning();
|
|
716
|
+
return project;
|
|
717
|
+
});
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
---
|
|
721
|
+
|
|
722
|
+
## Styling Patterns
|
|
723
|
+
|
|
724
|
+
### Tailwind CSS v4
|
|
725
|
+
|
|
726
|
+
```css
|
|
727
|
+
/* app.css */
|
|
728
|
+
@import "tailwindcss";
|
|
729
|
+
|
|
730
|
+
@theme {
|
|
731
|
+
--color-primary: oklch(0.7 0.15 200);
|
|
732
|
+
--color-secondary: oklch(0.6 0.1 250);
|
|
733
|
+
--font-sans: "Inter", system-ui, sans-serif;
|
|
734
|
+
}
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
### shadcn/ui with cn() Helper
|
|
738
|
+
|
|
739
|
+
```typescript
|
|
740
|
+
// lib/utils.ts
|
|
741
|
+
import { clsx, type ClassValue } from 'clsx';
|
|
742
|
+
import { twMerge } from 'tailwind-merge';
|
|
743
|
+
|
|
744
|
+
export function cn(...inputs: ClassValue[]) {
|
|
745
|
+
return twMerge(clsx(inputs));
|
|
746
|
+
}
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
### Icons: Lucide
|
|
750
|
+
|
|
751
|
+
```tsx
|
|
752
|
+
import { Search, Menu, X, ChevronRight } from 'lucide-react';
|
|
753
|
+
|
|
754
|
+
// Consistent sizing with size-* utility
|
|
755
|
+
<Search className="size-4" />
|
|
756
|
+
<Menu className="size-5" />
|
|
757
|
+
|
|
758
|
+
// React Native
|
|
759
|
+
import { Search, Menu } from 'lucide-react-native';
|
|
760
|
+
<Search color={colors.gray[500]} size={20} />
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
---
|
|
764
|
+
|
|
765
|
+
## Database Options
|
|
766
|
+
|
|
767
|
+
### Drizzle + PostgreSQL
|
|
768
|
+
|
|
769
|
+
**Schema with custom types (pgvector example):**
|
|
770
|
+
|
|
771
|
+
```typescript
|
|
772
|
+
// lib/schema.ts
|
|
773
|
+
import { pgTable, text, timestamp, uuid, customType } from 'drizzle-orm/pg-core';
|
|
774
|
+
|
|
775
|
+
// Custom pgvector type
|
|
776
|
+
const vector = customType<{ data: number[]; driverData: string }>({
|
|
777
|
+
dataType() {
|
|
778
|
+
return 'vector(1536)';
|
|
779
|
+
},
|
|
780
|
+
toDriver(value: number[]): string {
|
|
781
|
+
return `[${value.join(',')}]`;
|
|
782
|
+
},
|
|
783
|
+
fromDriver(value: string): number[] {
|
|
784
|
+
return JSON.parse(value.replace('[', '[').replace(']', ']'));
|
|
785
|
+
},
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
export const documents = pgTable('documents', {
|
|
789
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
790
|
+
content: text('content').notNull(),
|
|
791
|
+
embedding: vector('embedding'),
|
|
792
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
export const users = pgTable('users', {
|
|
796
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
797
|
+
email: text('email').notNull().unique(),
|
|
798
|
+
name: text('name').notNull(),
|
|
799
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
800
|
+
});
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
**Environment-aware client:**
|
|
804
|
+
|
|
805
|
+
```typescript
|
|
806
|
+
// lib/db.ts
|
|
807
|
+
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
808
|
+
import { Pool } from 'pg';
|
|
809
|
+
import * as schema from './schema';
|
|
810
|
+
|
|
811
|
+
const pool = new Pool({
|
|
812
|
+
connectionString: process.env.DATABASE_URL,
|
|
813
|
+
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
export const db = drizzle(pool, { schema });
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
### Convex Schema
|
|
820
|
+
|
|
821
|
+
```typescript
|
|
822
|
+
// convex/schema.ts
|
|
823
|
+
import { defineSchema, defineTable } from 'convex/server';
|
|
824
|
+
import { v } from 'convex/values';
|
|
825
|
+
|
|
826
|
+
export default defineSchema({
|
|
827
|
+
users: defineTable({
|
|
828
|
+
email: v.string(),
|
|
829
|
+
name: v.string(),
|
|
830
|
+
image: v.optional(v.string()),
|
|
831
|
+
}).index('by_email', ['email']),
|
|
832
|
+
|
|
833
|
+
projects: defineTable({
|
|
834
|
+
name: v.string(),
|
|
835
|
+
ownerId: v.id('users'),
|
|
836
|
+
createdAt: v.number(),
|
|
837
|
+
}).index('by_owner', ['ownerId']),
|
|
838
|
+
|
|
839
|
+
tasks: defineTable({
|
|
840
|
+
projectId: v.id('projects'),
|
|
841
|
+
title: v.string(),
|
|
842
|
+
completed: v.boolean(),
|
|
843
|
+
order: v.number(),
|
|
844
|
+
})
|
|
845
|
+
.index('by_project', ['projectId'])
|
|
846
|
+
.index('by_project_order', ['projectId', 'order']),
|
|
847
|
+
});
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
---
|
|
851
|
+
|
|
852
|
+
## Authentication
|
|
853
|
+
|
|
854
|
+
### Better Auth with Drizzle
|
|
855
|
+
|
|
856
|
+
```typescript
|
|
857
|
+
// lib/auth.ts
|
|
858
|
+
import { betterAuth } from 'better-auth';
|
|
859
|
+
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
|
860
|
+
import { db } from './db';
|
|
861
|
+
|
|
862
|
+
export const auth = betterAuth({
|
|
863
|
+
database: drizzleAdapter(db, { provider: 'pg' }),
|
|
864
|
+
emailAndPassword: { enabled: true },
|
|
865
|
+
socialProviders: {
|
|
866
|
+
google: {
|
|
867
|
+
clientId: process.env.GOOGLE_CLIENT_ID!,
|
|
868
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
869
|
+
},
|
|
870
|
+
},
|
|
871
|
+
});
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
```typescript
|
|
875
|
+
// lib/auth-client.ts
|
|
876
|
+
import { createAuthClient } from 'better-auth/react';
|
|
877
|
+
|
|
878
|
+
export const authClient = createAuthClient({
|
|
879
|
+
baseURL: process.env.VITE_APP_URL,
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
export const { useSession, signIn, signOut } = authClient;
|
|
883
|
+
```
|
|
884
|
+
|
|
885
|
+
### Better Auth with Convex
|
|
886
|
+
|
|
887
|
+
```typescript
|
|
888
|
+
// convex/auth.config.ts
|
|
889
|
+
import { convexAuth } from '@convex-dev/auth/server';
|
|
890
|
+
import Google from '@auth/core/providers/google';
|
|
891
|
+
|
|
892
|
+
export const { auth, signIn, signOut, store } = convexAuth({
|
|
893
|
+
providers: [Google],
|
|
894
|
+
});
|
|
895
|
+
```
|
|
896
|
+
|
|
897
|
+
### Protected Routes (TanStack Start)
|
|
898
|
+
|
|
899
|
+
```typescript
|
|
900
|
+
// routes/dashboard.tsx
|
|
901
|
+
import { createFileRoute, redirect } from '@tanstack/react-router';
|
|
902
|
+
import { auth } from '@/lib/auth';
|
|
903
|
+
|
|
904
|
+
export const Route = createFileRoute('/dashboard')({
|
|
905
|
+
beforeLoad: async ({ context }) => {
|
|
906
|
+
const session = await auth.api.getSession({
|
|
907
|
+
headers: context.request.headers,
|
|
908
|
+
});
|
|
909
|
+
if (!session) {
|
|
910
|
+
throw redirect({ to: '/login' });
|
|
911
|
+
}
|
|
912
|
+
return { session };
|
|
913
|
+
},
|
|
914
|
+
component: Dashboard,
|
|
915
|
+
});
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
---
|
|
919
|
+
|
|
920
|
+
## Code Organization
|
|
921
|
+
|
|
922
|
+
### Monorepo Structure
|
|
923
|
+
|
|
924
|
+
```
|
|
925
|
+
project/
|
|
926
|
+
├── apps/
|
|
927
|
+
│ ├── web/ # TanStack Start
|
|
928
|
+
│ │ ├── src/
|
|
929
|
+
│ │ │ ├── routes/ # File-based routing
|
|
930
|
+
│ │ │ ├── components/ # App components
|
|
931
|
+
│ │ │ └── lib/ # Utilities, queries
|
|
932
|
+
│ │ └── package.json
|
|
933
|
+
│ └── mobile/ # Expo
|
|
934
|
+
│ ├── app/ # Expo Router
|
|
935
|
+
│ ├── src/
|
|
936
|
+
│ │ ├── api/ # API client, schemas
|
|
937
|
+
│ │ ├── components/
|
|
938
|
+
│ │ ├── hooks/
|
|
939
|
+
│ │ └── lib/ # Store, utils
|
|
940
|
+
│ └── package.json
|
|
941
|
+
├── packages/
|
|
942
|
+
│ ├── ui/ # shadcn/ui components
|
|
943
|
+
│ └── database/ # Drizzle schema (if using)
|
|
944
|
+
├── convex/ # Convex functions (if using)
|
|
945
|
+
├── turbo.json
|
|
946
|
+
├── biome.json
|
|
947
|
+
└── package.json
|
|
948
|
+
```
|
|
949
|
+
|
|
950
|
+
### Import Alias
|
|
951
|
+
|
|
952
|
+
All imports use `@/` prefix:
|
|
953
|
+
|
|
954
|
+
```typescript
|
|
955
|
+
import { useAppStore } from '@/lib/store';
|
|
956
|
+
import { ArticleCard } from '@/components/article-card';
|
|
957
|
+
import { fetchValidated } from '@/api/client';
|
|
958
|
+
```
|
|
959
|
+
|
|
960
|
+
### Section Comments
|
|
961
|
+
|
|
962
|
+
Use this format for organizing large files:
|
|
963
|
+
|
|
964
|
+
```typescript
|
|
965
|
+
// ============================================================================
|
|
966
|
+
// TYPES
|
|
967
|
+
// ============================================================================
|
|
968
|
+
|
|
969
|
+
interface User {
|
|
970
|
+
// ...
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// ============================================================================
|
|
974
|
+
// STORE
|
|
975
|
+
// ============================================================================
|
|
976
|
+
|
|
977
|
+
export const useUserStore = create<UserState>()(...);
|
|
978
|
+
|
|
979
|
+
// ============================================================================
|
|
980
|
+
// HOOKS
|
|
981
|
+
// ============================================================================
|
|
982
|
+
|
|
983
|
+
export function useCurrentUser() {
|
|
984
|
+
// ...
|
|
985
|
+
}
|
|
986
|
+
```
|
|
987
|
+
|
|
988
|
+
---
|
|
989
|
+
|
|
990
|
+
## UI/Accessibility Guidelines
|
|
991
|
+
|
|
992
|
+
### Keyboard & Focus
|
|
993
|
+
|
|
994
|
+
- Full keyboard support per WAI-ARIA APG patterns
|
|
995
|
+
- Visible focus rings (`:focus-visible`)
|
|
996
|
+
- Focus management in modals/dialogs
|
|
997
|
+
- Never `outline: none` without replacement
|
|
998
|
+
|
|
999
|
+
### Touch Targets
|
|
1000
|
+
|
|
1001
|
+
- Minimum 44x44px on mobile
|
|
1002
|
+
- `touch-action: manipulation` to prevent double-tap zoom
|
|
1003
|
+
- Input font-size >= 16px to prevent iOS zoom
|
|
1004
|
+
|
|
1005
|
+
### Forms
|
|
1006
|
+
|
|
1007
|
+
- Keep submit enabled until request starts
|
|
1008
|
+
- Show spinner with original label during loading
|
|
1009
|
+
- Inline errors next to fields
|
|
1010
|
+
- Focus first error on submit
|
|
1011
|
+
- Warn on unsaved changes before navigation
|
|
1012
|
+
|
|
1013
|
+
### Performance
|
|
1014
|
+
|
|
1015
|
+
- Virtualize lists > 50 items (FlashList for RN)
|
|
1016
|
+
- Preload above-fold images, lazy-load rest
|
|
1017
|
+
- Profile with CPU/network throttling
|
|
1018
|
+
- Mutations target < 500ms
|
|
1019
|
+
|
|
1020
|
+
### Dark Mode
|
|
1021
|
+
|
|
1022
|
+
```typescript
|
|
1023
|
+
// Set color-scheme on html element
|
|
1024
|
+
document.documentElement.style.colorScheme = theme;
|
|
1025
|
+
|
|
1026
|
+
// Use CSS variables for theming
|
|
1027
|
+
:root {
|
|
1028
|
+
--background: oklch(1 0 0);
|
|
1029
|
+
--foreground: oklch(0.1 0 0);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
.dark {
|
|
1033
|
+
--background: oklch(0.1 0 0);
|
|
1034
|
+
--foreground: oklch(0.95 0 0);
|
|
1035
|
+
}
|
|
1036
|
+
```
|
|
1037
|
+
|
|
1038
|
+
---
|
|
1039
|
+
|
|
1040
|
+
## Quick Reference
|
|
1041
|
+
|
|
1042
|
+
### Commands
|
|
1043
|
+
|
|
1044
|
+
```bash
|
|
1045
|
+
# Development
|
|
1046
|
+
bun dev # Start all apps
|
|
1047
|
+
bun dev --filter=web # Start web only
|
|
1048
|
+
bun dev --filter=mobile # Start mobile only
|
|
1049
|
+
|
|
1050
|
+
# Building
|
|
1051
|
+
bun build # Build all
|
|
1052
|
+
turbo build --filter=web # Build specific
|
|
1053
|
+
|
|
1054
|
+
# Code quality
|
|
1055
|
+
bun lint # Lint all
|
|
1056
|
+
bun check # Lint + format with auto-fix
|
|
1057
|
+
|
|
1058
|
+
# Database (Drizzle)
|
|
1059
|
+
bun db:generate # Generate migration
|
|
1060
|
+
bun db:migrate # Apply migrations
|
|
1061
|
+
bun db:studio # Open Drizzle Studio
|
|
1062
|
+
|
|
1063
|
+
# Convex
|
|
1064
|
+
npx convex dev # Start Convex dev
|
|
1065
|
+
npx convex deploy # Deploy to production
|
|
1066
|
+
|
|
1067
|
+
# Mobile
|
|
1068
|
+
bun ios # Run on iOS simulator
|
|
1069
|
+
bun android # Run on Android emulator
|
|
1070
|
+
eas build --platform ios # Build for TestFlight
|
|
1071
|
+
```
|
|
1072
|
+
|
|
1073
|
+
### Key Dependencies
|
|
1074
|
+
|
|
1075
|
+
| Category | Web | Mobile |
|
|
1076
|
+
|----------|-----|--------|
|
|
1077
|
+
| Framework | TanStack Start | Expo SDK 54 |
|
|
1078
|
+
| Routing | TanStack Router | Expo Router |
|
|
1079
|
+
| State | TanStack Query | Zustand + TanStack Query |
|
|
1080
|
+
| Forms | Plain useState | Plain useState |
|
|
1081
|
+
| Tables | TanStack Table | - |
|
|
1082
|
+
| Lists | - | FlashList |
|
|
1083
|
+
| Auth | Better Auth | Better Auth |
|
|
1084
|
+
| Styling | Tailwind + shadcn | React Native StyleSheet |
|
|
1085
|
+
|
|
1086
|
+
---
|