diff --git a/.agents/skills/better-auth-best-practices/SKILL.md b/.agents/skills/better-auth-best-practices/SKILL.md new file mode 100644 index 0000000..3e6a4e1 --- /dev/null +++ b/.agents/skills/better-auth-best-practices/SKILL.md @@ -0,0 +1,175 @@ +--- +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()`. + +--- + +## 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) \ No newline at end of file diff --git a/.agents/skills/mantine-custom-components/SKILL.md b/.agents/skills/mantine-custom-components/SKILL.md new file mode 100644 index 0000000..6b0b476 --- /dev/null +++ b/.agents/skills/mantine-custom-components/SKILL.md @@ -0,0 +1,112 @@ +--- +name: mantine-custom-components +description: > + Build custom components that integrate with Mantine's theming, Styles API, and core features. + Use this skill when: (1) creating a new component using factory(), polymorphicFactory(), or + genericFactory(), (2) adding Styles API support (classNames, styles, vars, unstyled), (3) + implementing CSS variables via createVarsResolver, (4) building compound components with + sub-components and shared context, (5) registering a component with MantineProvider via + Component.extend(), or (6) any task involving Factory, useProps, useStyles, BoxProps, + StylesApiProps, or ElementProps in @mantine/core. +--- + +# Mantine Custom Components Skill + +## Component template + +```tsx +import { + Box, BoxProps, createVarsResolver, ElementProps, + factory, Factory, getRadius, MantineRadius, + StylesApiProps, useProps, useStyles, +} from '@mantine/core'; +import classes from './MyComponent.module.css'; + +export type MyComponentStylesNames = 'root' | 'inner'; +export type MyComponentVariant = 'filled' | 'outline'; +export type MyComponentCssVariables = { root: '--my-radius' }; + +export interface MyComponentProps + extends BoxProps, StylesApiProps, ElementProps<'div'> { + radius?: MantineRadius; +} + +export type MyComponentFactory = Factory<{ + props: MyComponentProps; + ref: HTMLDivElement; + stylesNames: MyComponentStylesNames; + vars: MyComponentCssVariables; + variant: MyComponentVariant; +}>; + +const defaultProps = { radius: 'md' } satisfies Partial; + +const varsResolver = createVarsResolver((_theme, { radius }) => ({ + root: { '--my-radius': getRadius(radius) }, +})); + +export const MyComponent = factory((_props) => { + const props = useProps('MyComponent', defaultProps, _props); + const { classNames, className, style, styles, unstyled, vars, attributes, radius, ...others } = props; + + const getStyles = useStyles({ + name: 'MyComponent', classes, props, + className, style, classNames, styles, unstyled, vars, attributes, varsResolver, + }); + + return ; +}); + +MyComponent.displayName = '@mantine/core/MyComponent'; +MyComponent.classes = classes; +``` + +## Factory variant — which to use + +| Scenario | Factory function | Type | +|---|---|---| +| Standard component | `factory()` | `Factory<{}>` | +| Supports `component` prop (polymorphic) | `polymorphicFactory()` | `PolymorphicFactory<{}>` — add `defaultComponent` and `defaultRef` | +| Props change based on a generic (e.g. `multiple`) | `genericFactory()` | `Factory<{ signature: ... }>` | + +Use `polymorphicFactory` sparingly — it adds TypeScript overhead and slows IDE autocomplete. + +## Factory type fields + +```ts +Factory<{ + props: MyComponentProps; // required + ref: HTMLDivElement; // element type for the forwarded ref + stylesNames: 'root' | 'inner'; // union of Styles API selectors + vars: { root: '--my-var' }; // CSS variable map per selector + variant: 'filled' | 'outline'; // accepted variant strings + staticComponents: { // sub-components (compound pattern) + Item: typeof MyComponentItem; + }; + compound?: boolean; // true = sub-component; disables theme classNames/styles/vars + ctx?: MyContextType; // passed to styles/vars resolvers as third arg + signature?: (...) => JSX.Element; // only for genericFactory +}> +``` + +## Theme integration + +Users and the theme can override defaults via `Component.extend()`: + +```ts +const theme = createTheme({ + components: { + MyComponent: MyComponent.extend({ + defaultProps: { radius: 'xl' }, + classNames: { root: 'my-root' }, + styles: { root: { color: 'red' } }, + vars: (_theme, props) => ({ root: { '--my-radius': getRadius(props.radius) } }), + }), + }, +}); +``` + +## References + +- **[`references/api.md`](references/api.md)** — All imports: `factory`, `useProps`, `useStyles`, `createVarsResolver`, `createSafeContext`, `StylesApiProps`, `CompoundStylesApiProps`, `BoxProps`, `ElementProps`, theme helpers (`getSize`, `getRadius`, etc.) +- **[`references/patterns.md`](references/patterns.md)** — Full examples: compound components with context, polymorphic component, generic component, theme integration diff --git a/.agents/skills/mantine-custom-components/references/api.md b/.agents/skills/mantine-custom-components/references/api.md new file mode 100644 index 0000000..911c74e --- /dev/null +++ b/.agents/skills/mantine-custom-components/references/api.md @@ -0,0 +1,407 @@ +# Custom Components API Reference + +## Table of Contents +- [Imports cheatsheet](#imports-cheatsheet) +- [factory / polymorphicFactory / genericFactory](#factory--polymorphicfactory--genericfactory) +- [Factory type fields](#factory-type-fields) +- [useProps](#usepropss) +- [useStyles](#usestyles) +- [createVarsResolver](#createvarsresolver) +- [StylesApiProps and CompoundStylesApiProps](#stylesapiprops-and-compoundstylesapiprops) +- [BoxProps and ElementProps](#boxprops-and-elementprops) +- [createSafeContext](#createsafecontext) +- [Theme helper functions](#theme-helper-functions) +- [Static properties](#static-properties) + +--- + +## Imports cheatsheet + +```ts +import { + // Factory functions + factory, + polymorphicFactory, + genericFactory, + + // Types + Factory, + PolymorphicFactory, + StylesApiProps, + CompoundStylesApiProps, + BoxProps, + ElementProps, + + // Hooks + useProps, + useStyles, + + // Vars + createVarsResolver, + + // Context + createSafeContext, + + // Base component + Box, + + // Theme helpers + getSize, + getSpacing, + getRadius, + getFontSize, + getLineHeight, + getShadow, + rem, + em, +} from '@mantine/core'; +``` + +--- + +## factory / polymorphicFactory / genericFactory + +### factory() + +Standard factory for non-polymorphic components. + +```ts +factory( + ui: (props: Payload['props'] & { ref?: React.Ref }) => React.ReactNode +): MantineComponent +``` + +The returned component has static properties: `.extend()`, `.withProps()`, `.classes`, `.displayName`, `.varsResolver` (if vars are used), and any sub-components assigned. + +### polymorphicFactory() + +For components that accept a `component` prop to render as a different element. Same signature as `factory()`, but uses `PolymorphicFactory` type. + +```ts +// Type uses PolymorphicFactory<{}> instead of Factory<{}> +export type MyFactory = PolymorphicFactory<{ + props: MyProps; + defaultRef: HTMLButtonElement; // default ref type + defaultComponent: 'button'; // default element + stylesNames: ...; + vars: ...; +}>; + +export const My = polymorphicFactory((_props) => { ... }); +``` + +### genericFactory() + +For components whose prop types depend on a generic argument. + +```ts +// Factory uses 'signature' field +export type MyFactory = Factory<{ + props: MyProps; + signature: (props: MyProps) => React.JSX.Element; + ref: HTMLDivElement; + // ... other fields +}>; + +export const My = genericFactory((_props) => { ... }); +``` + +--- + +## Factory type fields + +All fields except `props` are optional. + +```ts +Factory<{ + props: MyComponentProps; + + // Forwarded ref element type + ref: HTMLDivElement; + + // Union of Styles API selector strings (must match CSS module class names) + stylesNames: 'root' | 'label' | 'icon'; + + // CSS variables definition: { selectorName: '--var-name' | '--other-var' } + vars: { + root: '--my-height' | '--my-color'; + label: '--my-label-fz'; + }; + + // Accepted values for the variant prop + variant: 'filled' | 'outline' | 'subtle'; + + // Sub-components for compound pattern + staticComponents: { + Item: typeof MyItem; + Label: typeof MyLabel; + }; + + // Set to true for sub-components — disables theme classNames/styles/vars for this component + compound: true; + + // Context type passed as 3rd argument to styles/vars resolvers + ctx: { opened: boolean }; + + // Generic signature (genericFactory only) + signature: (props: MyProps) => React.JSX.Element; +}> +``` + +--- + +## useProps + +Merges default props from three sources in priority order (highest → lowest): +1. Props passed by the user +2. Default props from `MantineProvider` theme (`components.MyComponent.defaultProps`) +3. Component-level `defaultProps` + +```ts +useProps>( + componentName: string, // must match the name used in theme.components + defaultProps: Partial, // use 'satisfies Partial' for correct inference + props: T +): T +``` + +**Important:** Always call `useProps` before destructuring. Always use `satisfies Partial` (not `: Partial`) for `defaultProps` to preserve narrowed types. + +```ts +const defaultProps = { size: 'md', variant: 'filled' } satisfies Partial; + +const props = useProps('MyComponent', defaultProps, _props); +const { className, style, classNames, styles, unstyled, vars, attributes, ...others } = props; +``` + +--- + +## useStyles + +Returns a `getStyles` function that provides `className` and `style` for each Styles API selector. + +```ts +useStyles(input: { + name: string | string[]; // component name(s) for static CSS class generation + classes: Record; // CSS module classes object + props: Payload['props']; + stylesCtx?: Payload['ctx']; // optional context for styles/vars resolvers + className?: string; // spread to rootSelector + style?: MantineStyleProp; // spread to rootSelector + rootSelector?: string; // which selector gets className/style (default: 'root') + unstyled?: boolean; + classNames?: ClassNames; + styles?: Styles; + vars?: PartialVarsResolver; + varsResolver?: VarsResolver; + attributes?: Attributes; +}): GetStylesApi +``` + +**`getStyles` function:** +```ts +getStyles( + selector: StylesNames, + options?: { + className?: string; // additional className merged in + style?: CSSProperties; // additional style merged in + focusable?: boolean; + active?: boolean; + withStaticClass?: boolean; + } +): { className: string; style: CSSProperties } +``` + +Usage: +```tsx + +
+``` + +--- + +## createVarsResolver + +Defines how component props map to CSS variables. + +```ts +createVarsResolver( + resolver: ( + theme: MantineTheme, + props: Payload['props'], + ctx: Payload['ctx'] // only if Factory has ctx field + ) => TransformVars +): VarsResolver +``` + +The resolver must return an object matching the `vars` structure defined in `Factory`: + +```ts +// Factory vars: { root: '--my-height' | '--my-color' } +const varsResolver = createVarsResolver((_theme, { size, color }) => ({ + root: { + '--my-height': getSize(size, 'my-height'), + '--my-color': color ?? undefined, // undefined = CSS var not set (uses CSS fallback) + }, +})); +``` + +Assign to the component after creation: +```ts +MyComponent.varsResolver = varsResolver; +``` + +--- + +## StylesApiProps and CompoundStylesApiProps + +**`StylesApiProps`** — extend on the root/main component's props interface: +```ts +interface StylesApiProps { + unstyled?: boolean; + variant?: Payload['variant'] | (string & {}); + classNames?: ClassNames; // { root: 'my-class', inner: 'other' } or callback + styles?: Styles; // { root: { color: 'red' } } or callback + vars?: PartialVarsResolver; // (theme, props) => { root: { '--my-var': '...' } } + attributes?: Attributes; // { root: { 'data-custom': value } } +} +``` + +**`CompoundStylesApiProps`** — extend on sub-component (compound) props instead. Subset of `StylesApiProps` — no `unstyled` or `attributes`. + +```ts +interface CompoundStylesApiProps + extends Omit, 'unstyled' | 'attributes'> {} +``` + +Compound sub-components also use `Factory<{ ..., compound: true }>` and access styles via the parent context's `getStyles`. + +--- + +## BoxProps and ElementProps + +**`BoxProps`** — extends `MantineStyleProps`, adds: +```ts +interface BoxProps extends MantineStyleProps { + className?: string; + style?: MantineStyleProp; // accepts function: (theme) => CSSProperties + mod?: string | Record | (string | Record)[]; // data-* attributes + hiddenFrom?: MantineBreakpoint; // hidden at this breakpoint and above + visibleFrom?: MantineBreakpoint; // visible only at this breakpoint and above + lightHidden?: boolean; // hidden in light color scheme + darkHidden?: boolean; // hidden in dark color scheme +} +``` + +**`MantineStyleProps`** — shorthand style props (all accept responsive `{ base, sm, md, lg, xl }` objects): + +| Prop | CSS property | Prop | CSS property | +|---|---|---|---| +| `m` `mt` `mb` `ml` `mr` `mx` `my` `ms` `me` | margin variants | `p` `pt` `pb` `pl` `pr` `px` `py` `ps` `pe` | padding variants | +| `w` `miw` `maw` | width | `h` `mih` `mah` | height | +| `c` | color | `bg` | background | +| `fz` | font-size | `fw` | font-weight | +| `ff` | font-family | `fs` | font-style | +| `lh` | line-height | `lts` | letter-spacing | +| `ta` | text-align | `tt` | text-transform | +| `td` | text-decoration | `bd` | border | +| `bdrs` | border-radius | `opacity` | opacity | +| `pos` | position | `top` `left` `bottom` `right` `inset` | positioning | +| `display` | display | `flex` | flex | + +**`ElementProps`** — gets HTML element props, remapping `style` to Mantine's type: +```ts +// Include all div props except style (remapped) and any conflicting props +interface MyProps extends ElementProps<'div'> {} + +// Omit conflicting HTML attrs (e.g. input has native 'size' and 'color') +interface MyProps extends ElementProps<'input', 'size' | 'color'> { + size?: MantineSize; + color?: MantineColor; +} + +// Can also accept a React component type instead of element string +interface MyProps extends ElementProps {} +``` + +--- + +## createSafeContext + +Used inside compound components to share state from the parent to sub-components. + +```ts +createSafeContext( + errorMessage: string // thrown when hook is used outside the provider +): [ + Context: React.Context, + useContext: () => ContextValue // throws errorMessage if used outside provider +] +``` + +**Usage pattern (in ComponentName.context.ts):** +```ts +import { createSafeContext, GetStylesApi } from '@mantine/core'; +import { MyFactory } from './MyComponent'; + +interface MyContextValue { + getStyles: GetStylesApi; + // other shared state... +} + +export const [MyProvider, useMyContext] = createSafeContext( + 'MyComponent was not found in tree' +); +``` + +In the root component: +```tsx +return ( + + {children} + +); +``` + +In sub-components: +```tsx +const { getStyles } = useMyContext(); +return
; +``` + +--- + +## Theme helper functions + +Use these in `createVarsResolver` to convert Mantine size tokens to CSS values: + +| Function | Input | Output example | +|---|---|---| +| `getSize(size, prefix)` | `'sm'`, `'button-height'` | `'var(--mantine-button-height-sm)'` | +| `getSpacing(size)` | `'md'` or `16` | `'var(--mantine-spacing-md)'` or `'1rem'` | +| `getRadius(size)` | `'sm'` or `4` | `'var(--mantine-radius-sm)'` or `'0.25rem'` | +| `getFontSize(size)` | `'sm'` | `'var(--mantine-font-size-sm)'` | +| `getLineHeight(size)` | `'sm'` | `'var(--mantine-line-height-sm)'` | +| `getShadow(size)` | `'md'` | `'var(--mantine-shadow-md)'` | +| `rem(value)` | `16` | `'1rem'` | +| `em(value)` | `16` | `'1em'` | + +Return `undefined` from a var resolver entry to leave that CSS variable unset (CSS fallback applies). + +--- + +## Static properties + +These must be set on every component after creation: + +```ts +MyComponent.displayName = '@mantine/core/MyComponent'; // or '@mantine/package/Name' +MyComponent.classes = classes; // CSS module classes object +MyComponent.varsResolver = varsResolver; // only if component defines vars + +// Sub-components (compound pattern) +MyComponent.Item = MyItem; +MyComponent.Label = MyLabel; +``` + +`.extend()` and `.withProps()` are added automatically by `factory()`. diff --git a/.agents/skills/mantine-custom-components/references/patterns.md b/.agents/skills/mantine-custom-components/references/patterns.md new file mode 100644 index 0000000..d243e99 --- /dev/null +++ b/.agents/skills/mantine-custom-components/references/patterns.md @@ -0,0 +1,431 @@ +# Custom Component Patterns + +## Table of Contents +- [Minimal component (no styles API)](#minimal-component-no-styles-api) +- [Component with CSS variables](#component-with-css-variables) +- [Compound component with context](#compound-component-with-context) +- [Polymorphic component](#polymorphic-component) +- [Generic component](#generic-component) +- [Theme integration](#theme-integration) +- [Namespace exports](#namespace-exports) + +--- + +## Minimal component (no styles API) + +When you don't need theming/Styles API support — just Box + useProps. + +```tsx +import { Box, BoxProps, ElementProps, factory, Factory, useProps } from '@mantine/core'; + +export interface MinimalProps extends BoxProps, ElementProps<'div'> { + label?: string; +} + +export type MinimalFactory = Factory<{ + props: MinimalProps; + ref: HTMLDivElement; +}>; + +const defaultProps = {} satisfies Partial; + +export const Minimal = factory((_props) => { + const props = useProps('Minimal', defaultProps, _props); + const { label, children, ...others } = props; + + return ( + + {label && {label}} + {children} + + ); +}); + +Minimal.displayName = '@mantine/core/Minimal'; +``` + +--- + +## Component with CSS variables + +Full example with Styles API, CSS variables, and theme integration. + +**MyComponent.module.css:** +```css +.root { + border-radius: var(--my-radius); + padding: var(--my-padding); +} + +.inner { + font-size: var(--my-fz); +} +``` + +**MyComponent.tsx:** +```tsx +import { + Box, BoxProps, createVarsResolver, ElementProps, factory, Factory, + getFontSize, getRadius, getSpacing, MantineFontSize, MantineRadius, + MantineSpacing, StylesApiProps, useProps, useStyles, +} from '@mantine/core'; +import classes from './MyComponent.module.css'; + +export type MyComponentStylesNames = 'root' | 'inner'; +export type MyComponentVariant = 'filled' | 'outline'; +export type MyComponentCssVariables = { + root: '--my-radius' | '--my-padding'; + inner: '--my-fz'; +}; + +export interface MyComponentProps + extends BoxProps, StylesApiProps, ElementProps<'div'> { + radius?: MantineRadius; + padding?: MantineSpacing; + size?: MantineFontSize; + variant?: MyComponentVariant; +} + +export type MyComponentFactory = Factory<{ + props: MyComponentProps; + ref: HTMLDivElement; + stylesNames: MyComponentStylesNames; + vars: MyComponentCssVariables; + variant: MyComponentVariant; +}>; + +const defaultProps = { + radius: 'sm', + padding: 'md', + size: 'md', +} satisfies Partial; + +const varsResolver = createVarsResolver((_theme, { radius, padding, size }) => ({ + root: { + '--my-radius': getRadius(radius), + '--my-padding': getSpacing(padding), + }, + inner: { + '--my-fz': getFontSize(size), + }, +})); + +export const MyComponent = factory((_props) => { + const props = useProps('MyComponent', defaultProps, _props); + const { + classNames, className, style, styles, unstyled, vars, attributes, + radius, padding, size, + children, + ...others + } = props; + + const getStyles = useStyles({ + name: 'MyComponent', + classes, + props, + className, + style, + classNames, + styles, + unstyled, + vars, + attributes, + varsResolver, + }); + + return ( + +
{children}
+
+ ); +}); + +MyComponent.displayName = '@mantine/core/MyComponent'; +MyComponent.classes = classes; +MyComponent.varsResolver = varsResolver; +``` + +--- + +## Compound component with context + +Pattern for components with typed sub-components (e.g. `Card.Section`, `Tabs.Tab`). + +**MyCard.context.ts:** +```ts +import { createSafeContext, GetStylesApi } from '@mantine/core'; +import type { MyCardFactory } from './MyCard'; + +interface MyCardContextValue { + getStyles: GetStylesApi; + orientation: 'horizontal' | 'vertical'; +} + +export const [MyCardProvider, useMyCardContext] = createSafeContext( + 'MyCard component was not found in tree' +); +``` + +**MyCardSection.tsx** (sub-component): +```tsx +import { + Box, BoxProps, CompoundStylesApiProps, ElementProps, + factory, Factory, useProps, useStyles, +} from '@mantine/core'; +import { useMyCardContext } from './MyCard.context'; +import classes from './MyCard.module.css'; + +export type MyCardSectionStylesNames = 'section'; + +export interface MyCardSectionProps + extends BoxProps, CompoundStylesApiProps, ElementProps<'div'> { + withBorder?: boolean; +} + +export type MyCardSectionFactory = Factory<{ + props: MyCardSectionProps; + ref: HTMLDivElement; + stylesNames: MyCardSectionStylesNames; + compound: true; // marks as a compound sub-component +}>; + +const defaultProps = {} satisfies Partial; + +export const MyCardSection = factory((_props) => { + const props = useProps('MyCardSection', defaultProps, _props); + const { className, style, classNames, styles, withBorder, children, ...others } = props; + + // Access styles from parent context + const { getStyles } = useMyCardContext(); + + return ( + + {children} + + ); +}); + +MyCardSection.displayName = '@mantine/core/MyCardSection'; +``` + +**MyCard.tsx** (root component): +```tsx +import { MyCardProvider } from './MyCard.context'; + +// ... (same Styles API setup as above) + +export type MyCardFactory = Factory<{ + props: MyCardProps; + ref: HTMLDivElement; + stylesNames: 'root' | 'section'; // include sub-component selectors too + staticComponents: { + Section: typeof MyCardSection; + }; +}>; + +export const MyCard = factory((_props) => { + const props = useProps('MyCard', defaultProps, _props); + const { + classNames, className, style, styles, unstyled, vars, attributes, + orientation, children, ...others + } = props; + + const getStyles = useStyles({ ... }); + + return ( + + {children} + + ); +}); + +MyCard.displayName = '@mantine/core/MyCard'; +MyCard.classes = classes; +MyCard.Section = MyCardSection; // attach sub-component +``` + +--- + +## Polymorphic component + +Supports `component` prop to render as any element or React component. + +```tsx +import { + Box, BoxProps, polymorphicFactory, PolymorphicFactory, + StylesApiProps, useProps, useStyles, +} from '@mantine/core'; + +export type MyLinkStylesNames = 'root'; + +export interface MyLinkProps extends BoxProps, StylesApiProps { + active?: boolean; +} + +export type MyLinkFactory = PolymorphicFactory<{ + props: MyLinkProps; + defaultRef: HTMLAnchorElement; + defaultComponent: 'a'; // renders as unless component prop is provided + stylesNames: MyLinkStylesNames; +}>; + +const defaultProps = {} satisfies Partial; + +export const MyLink = polymorphicFactory((_props) => { + const props = useProps('MyLink', defaultProps, _props); + const { + classNames, className, style, styles, unstyled, vars, attributes, + active, ...others + } = props; + + const getStyles = useStyles({ + name: 'MyLink', classes, props, className, style, + classNames, styles, unstyled, vars, attributes, + }); + + return ( + + ); +}); + +MyLink.displayName = '@mantine/core/MyLink'; +MyLink.classes = classes; +``` + +**Usage:** +```tsx +Link +As button +Router link +``` + +--- + +## Generic component + +For components where prop types depend on a generic parameter. + +```tsx +import { factory, Factory, genericFactory, useProps } from '@mantine/core'; + +type SelectValue = M extends true ? string[] : string | null; + +export interface MySelectProps + extends BoxProps, StylesApiProps { + multiple?: M; + value?: SelectValue; + defaultValue?: SelectValue; + onChange?: (value: SelectValue) => void; +} + +export type MySelectFactory = Factory<{ + props: MySelectProps; + ref: HTMLDivElement; + signature: (props: MySelectProps) => React.JSX.Element; + stylesNames: 'root'; +}>; + +const defaultProps = { multiple: false } satisfies Partial; + +export const MySelect = genericFactory((_props) => { + const props = useProps('MySelect', defaultProps as any, _props); + const { multiple, value, onChange, ...others } = props; + // ... +}); + +MySelect.displayName = '@mantine/core/MySelect'; +``` + +**Usage:** +```tsx +// TypeScript infers value as string | null + setVal(v)} /> + +// TypeScript infers value as string[] + setVals(v)} /> +``` + +--- + +## Theme integration + +Components built with `factory()` automatically get `.extend()` and `.withProps()`. + +**`.extend()`** — for theme-level configuration in `createTheme`: +```tsx +const theme = createTheme({ + components: { + MyComponent: MyComponent.extend({ + // Override default props + defaultProps: { + radius: 'xl', + size: 'lg', + }, + // Add classes to selectors + classNames: { + root: 'my-root-class', + inner: 'my-inner-class', + }, + // Add inline styles to selectors + styles: { + root: { border: '1px solid red' }, + }, + // Or use a callback for theme-aware styles + styles: (theme) => ({ + root: { background: theme.colors.blue[0] }, + }), + // Override CSS variables + vars: (_theme, props) => ({ + root: { '--my-radius': props.radius ? getRadius(props.radius) : undefined }, + }), + }), + }, +}); +``` + +**`.withProps()`** — create a pre-configured variant at the call site: +```tsx +const BigMyComponent = MyComponent.withProps({ size: 'xl', radius: 'lg' }); + +// Same as MyComponent but with size and radius pre-set +Content +``` + +--- + +## Namespace exports + +Add at the bottom of the component file or `index.ts` to let consumers access types without extra imports. + +```tsx +export namespace MyComponent { + export type Props = MyComponentProps; + export type StylesNames = MyComponentStylesNames; + export type CssVariables = MyComponentCssVariables; + export type Factory = MyComponentFactory; + export type Variant = MyComponentVariant; + + export namespace Section { + export type Props = MyComponentSectionProps; + export type StylesNames = MyComponentSectionStylesNames; + export type Factory = MyComponentSectionFactory; + } +} +``` + +**Usage:** +```ts +import { MyComponent } from './MyComponent'; + +// No need to import MyComponentProps separately +const props: MyComponent.Props = { radius: 'md' }; +``` diff --git a/.agents/skills/tanstack-form/SKILL.md b/.agents/skills/tanstack-form/SKILL.md new file mode 100644 index 0000000..944941a --- /dev/null +++ b/.agents/skills/tanstack-form/SKILL.md @@ -0,0 +1,416 @@ +--- +name: tanstack-form +description: Headless, performant, and type-safe form state management for TS/JS, React, Vue, Angular, Solid, Lit, and Svelte. +--- + + +## Overview + +TanStack Form is a headless form library with deep TypeScript integration. It provides field-level and form-level validation (sync/async), array fields, linked/dependent fields, fine-grained reactivity, and schema validation adapter support (Zod, Valibot, Yup). + +**Package:** `@tanstack/react-form` +**Adapters:** `@tanstack/zod-form-adapter`, `@tanstack/valibot-form-adapter` +**Status:** Stable (v1) + +## Installation + +```bash +npm install @tanstack/react-form +# Optional schema adapters: +npm install @tanstack/zod-form-adapter zod +npm install @tanstack/valibot-form-adapter valibot +``` + +## Core: useForm + +```tsx +import { useForm } from '@tanstack/react-form' + +function MyForm() { + const form = useForm({ + defaultValues: { + firstName: '', + lastName: '', + email: '', + age: 0, + }, + onSubmit: async ({ value }) => { + // value is fully typed + await submitToServer(value) + }, + onSubmitInvalid: ({ value, formApi }) => { + console.log('Validation failed:', formApi.state.errors) + }, + }) + + return ( +
{ + e.preventDefault() + e.stopPropagation() + form.handleSubmit() + }} + > + {/* Fields */} + ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })} + children={({ canSubmit, isSubmitting }) => ( + + )} + /> + + ) +} +``` + +## Fields (form.Field) + +```tsx + + value.length < 3 ? 'Must be at least 3 characters' : undefined, + }} + children={(field) => ( +
+ + field.handleChange(e.target.value)} + /> + {field.state.meta.isTouched && field.state.meta.errors.length > 0 && ( + {field.state.meta.errors.join(', ')} + )} +
+ )} +/> + + + + {(field) => ( + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + /> + )} + +``` + +## Validation + +### Validation Timing + +| Cause | When | +|-------|------| +| `onChange` | After every value change | +| `onBlur` | When field loses focus | +| `onSubmit` | During submission | +| `onMount` | When field mounts | + +### Synchronous Validation + +```tsx + { + if (value < 18) return 'Must be 18 or older' + return undefined // undefined = valid + }, + onBlur: ({ value }) => { + if (!value) return 'Required' + return undefined + }, + }} +/> +``` + +### Asynchronous Validation + +```tsx + { + const res = await fetch(`/api/check-username?q=${value}`) + const { available } = await res.json() + if (!available) return 'Username taken' + return undefined + }, + }} +> + {(field) => ( + <> + field.handleChange(e.target.value)} /> + {field.state.meta.isValidating && Checking...} + + )} + +``` + +### Schema Validation (Zod) + +```tsx +import { zodValidator } from '@tanstack/zod-form-adapter' +import { z } from 'zod' + +const form = useForm({ + defaultValues: { email: '', age: 0 }, + validatorAdapter: zodValidator(), + onSubmit: async ({ value }) => { /* ... */ }, +}) + + + + +``` + +### Form-Level Validation + +```tsx +const form = useForm({ + defaultValues: { password: '', confirmPassword: '' }, + validators: { + onChange: ({ value }) => { + if (value.password !== value.confirmPassword) { + return 'Passwords do not match' + } + return undefined + }, + }, +}) +``` + +### Linked/Dependent Fields + +```tsx + { + const password = fieldApi.form.getFieldValue('password') + if (value !== password) return 'Passwords do not match' + return undefined + }, + }} +/> +``` + +## Array Fields + +```tsx + + {(field) => ( +
+ {field.state.value.map((_, index) => ( +
+ + {(subField) => ( + subField.handleChange(e.target.value)} + /> + )} + + +
+ ))} + +
+ )} +
+``` + +### Array Methods + +```typescript +field.pushValue(item) // Add to end +field.insertValue(index, item) // Insert at index +field.replaceValue(index, item) // Replace at index +field.removeValue(index) // Remove at index +field.swapValues(indexA, indexB) // Swap positions +field.moveValue(from, to) // Move position +``` + +## Listeners (Side Effects) + +```tsx + { + // Side effect: reset dependent fields + form.setFieldValue('state', '') + form.setFieldValue('postalCode', '') + }, + }} +/> +``` + +## Reactivity (form.Subscribe & useStore) + +```tsx +// Render-prop subscription (fine-grained) + ({ canSubmit: state.canSubmit, isDirty: state.isDirty })} + children={({ canSubmit, isDirty }) => ( +
+ {isDirty && Unsaved changes} + +
+ )} +/> + +// Hook-based subscription +function FormStatus() { + const isValid = form.useStore((s) => s.isValid) + return isValid ? null :

Fix errors

+} +``` + +## Form State + +```typescript +interface FormState { + values: TFormData + errors: ValidationError[] + errorMap: Record + isFormValid: boolean + isFieldsValid: boolean + isValid: boolean // isFormValid && isFieldsValid + isTouched: boolean + isPristine: boolean + isDirty: boolean + isSubmitting: boolean + isSubmitted: boolean + isSubmitSuccessful: boolean + submissionAttempts: number + canSubmit: boolean // isValid && !isSubmitting +} +``` + +## Field State + +```typescript +interface FieldState { + value: TData + meta: { + isTouched: boolean + isDirty: boolean + isPristine: boolean + isValidating: boolean + errors: ValidationError[] + errorMap: Record + } +} +``` + +## FormApi Methods + +```typescript +form.handleSubmit() +form.reset() +form.getFieldValue(field) +form.setFieldValue(field, value) +form.getFieldMeta(field) +form.setFieldMeta(field, updater) +form.validateAllFields(cause) +form.validateField(field, cause) +form.deleteField(field) +``` + +## Shared Form Options (formOptions) + +```tsx +import { formOptions } from '@tanstack/react-form' + +const sharedOpts = formOptions({ + defaultValues: { firstName: '', lastName: '' }, +}) + +// Reuse across components +const form = useForm({ + ...sharedOpts, + onSubmit: async ({ value }) => { /* ... */ }, +}) +``` + +## Server-Side Validation + +```tsx +// TanStack Start / Next.js server action +import { ServerValidateError } from '@tanstack/react-form/nextjs' + +export async function validateForm(data: FormData) { + const email = data.get('email') as string + if (await checkEmailExists(email)) { + throw new ServerValidateError({ + form: 'Submission failed', + fields: { email: 'Email already registered' }, + }) + } +} +``` + +## TypeScript Integration + +```tsx +// Type-safe field paths with DeepKeys +interface UserForm { + name: string + address: { street: string; city: string } + tags: string[] + contacts: Array<{ name: string; phone: string }> +} + +// TypeScript auto-completes all valid paths: +// 'name', 'address', 'address.street', 'address.city', 'tags', 'contacts' + // OK + // Type Error! +``` + +## Best Practices + +1. **Always call `e.preventDefault()` and `e.stopPropagation()`** on form submit +2. **Always attach `onBlur={field.handleBlur}`** for blur validation and isTouched tracking +3. **Use `mode="array"`** for array fields to get array methods +4. **Return `undefined`** (not null/false) for valid validators +5. **Use `asyncDebounceMs`** for async validators to prevent API spam +6. **Check `isTouched` before showing errors** for better UX +7. **Use `form.Subscribe` with selectors** to minimize re-renders +8. **Use `formOptions`** for shared configuration across components +9. **Use schema validators** (Zod/Valibot) for complex validation rules +10. **Use `onChangeListenTo`** for cross-field validation dependencies + +## Common Pitfalls + +- Forgetting `e.preventDefault()` on form submit (causes page reload) +- Not attaching `onBlur` to inputs (breaks blur validation and isTouched) +- Returning `null` or `false` instead of `undefined` for valid fields +- Using `mode="array"` incorrectly (only needed on the array field itself, not sub-fields) +- Subscribing to entire form state instead of using selectors (unnecessary re-renders) +- Not using `asyncDebounceMs` with async validators (fires on every keystroke) diff --git a/.agents/skills/tanstack-query/SKILL.md b/.agents/skills/tanstack-query/SKILL.md new file mode 100644 index 0000000..1538eb4 --- /dev/null +++ b/.agents/skills/tanstack-query/SKILL.md @@ -0,0 +1,849 @@ +--- +name: tanstack-query +description: Powerful asynchronous state management, server-state utilities, and data fetching for TS/JS, React, Vue, Solid, Svelte & Angular. +--- + + +## Overview + +TanStack Query (formerly React Query) manages server state - data that lives on the server and needs to be fetched, cached, synchronized, and updated. It provides automatic caching, background refetching, stale-while-revalidate patterns, pagination, infinite scrolling, and optimistic updates out of the box. + +**Package:** `@tanstack/react-query` +**Devtools:** `@tanstack/react-query-devtools` +**Current Version:** v5 + +## Installation + +```bash +npm install @tanstack/react-query +npm install -D @tanstack/react-query-devtools # Optional +``` + +## Setup + +```tsx +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60, // 1 minute + gcTime: 1000 * 60 * 5, // 5 minutes (garbage collection) + retry: 3, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + }, + }, +}) + +function App() { + return ( + + + + + ) +} +``` + +## Core Concepts + +### Query Keys + +Query keys uniquely identify cached data. They must be serializable arrays: + +```tsx +// Simple key +useQuery({ queryKey: ['todos'], queryFn: fetchTodos }) + +// With variables (dependency array pattern) +useQuery({ queryKey: ['todos', { status, page }], queryFn: fetchTodos }) + +// Hierarchical keys for invalidation +useQuery({ queryKey: ['todos', todoId], queryFn: () => fetchTodo(todoId) }) +useQuery({ queryKey: ['todos', todoId, 'comments'], queryFn: () => fetchComments(todoId) }) + +// Invalidation matches prefixes: +// queryClient.invalidateQueries({ queryKey: ['todos'] }) +// ^ Invalidates ALL queries starting with 'todos' +``` + +### Query Functions + +```tsx +// Query function receives a QueryFunctionContext +useQuery({ + queryKey: ['todos', todoId], + queryFn: async ({ queryKey, signal, meta }) => { + const [_key, id] = queryKey + const response = await fetch(`/api/todos/${id}`, { signal }) + if (!response.ok) throw new Error('Failed to fetch') + return response.json() + }, +}) + +// Using the signal for automatic cancellation +useQuery({ + queryKey: ['todos'], + queryFn: async ({ signal }) => { + const response = await fetch('/api/todos', { signal }) + return response.json() + }, +}) +``` + +### queryOptions Helper + +Create reusable, type-safe query configurations: + +```tsx +import { queryOptions } from '@tanstack/react-query' + +export const todosQueryOptions = queryOptions({ + queryKey: ['todos'], + queryFn: fetchTodos, + staleTime: 5000, +}) + +export const todoQueryOptions = (todoId: string) => + queryOptions({ + queryKey: ['todos', todoId], + queryFn: () => fetchTodo(todoId), + enabled: !!todoId, + }) + +// Usage +const { data } = useQuery(todosQueryOptions) +const { data } = useSuspenseQuery(todoQueryOptions(id)) +await queryClient.prefetchQuery(todosQueryOptions) +``` + +## Queries (useQuery) + +### Basic Usage + +```tsx +import { useQuery } from '@tanstack/react-query' + +function Todos() { + const { + data, + error, + isLoading, // First load, no data yet + isFetching, // Any fetch in progress (including background) + isError, + isSuccess, + isPending, // No data yet (same as isLoading in most cases) + status, // 'pending' | 'error' | 'success' + fetchStatus, // 'fetching' | 'paused' | 'idle' + refetch, + isStale, + isPlaceholderData, + dataUpdatedAt, + errorUpdatedAt, + } = useQuery({ + queryKey: ['todos'], + queryFn: fetchTodos, + }) + + if (isLoading) return + if (isError) return + return +} +``` + +### Query Options + +```tsx +useQuery({ + queryKey: ['todos'], + queryFn: fetchTodos, + + // Freshness + staleTime: 5000, // ms data stays fresh (default: 0) + gcTime: 300000, // ms unused data stays in cache (default: 5 min) + + // Refetching + refetchInterval: 10000, // Poll every 10s + refetchIntervalInBackground: false, // Don't poll when tab hidden + refetchOnMount: true, // Refetch on component mount if stale + refetchOnWindowFocus: true, // Refetch on window focus if stale + refetchOnReconnect: true, // Refetch on network reconnect + + // Retry + retry: 3, // Number of retries (or function) + retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), + + // Conditional + enabled: !!userId, // Only run when truthy + + // Initial/placeholder data + initialData: () => cachedData, + initialDataUpdatedAt: Date.now() - 10000, + placeholderData: (previousData) => previousData, // keepPreviousData pattern + placeholderData: initialTodos, + + // Transform + select: (data) => data.filter(todo => !todo.done), + + // Structural sharing (default: true) + structuralSharing: true, + + // Network mode + networkMode: 'online', // 'online' | 'always' | 'offlineFirst' + + // Meta (accessible in query function context) + meta: { purpose: 'user-facing' }, +}) +``` + +## Mutations (useMutation) + +### Basic Usage + +```tsx +import { useMutation, useQueryClient } from '@tanstack/react-query' + +function AddTodo() { + const queryClient = useQueryClient() + + const mutation = useMutation({ + mutationFn: (newTodo: { title: string }) => { + return fetch('/api/todos', { + method: 'POST', + body: JSON.stringify(newTodo), + }).then(res => res.json()) + }, + // Lifecycle callbacks + onMutate: async (variables) => { + // Called before mutationFn + // Good for optimistic updates + return { previousTodos } // context for onError + }, + onSuccess: (data, variables, context) => { + // Invalidate related queries + queryClient.invalidateQueries({ queryKey: ['todos'] }) + }, + onError: (error, variables, context) => { + // Rollback optimistic updates + queryClient.setQueryData(['todos'], context.previousTodos) + }, + onSettled: (data, error, variables, context) => { + // Always runs (success or error) + queryClient.invalidateQueries({ queryKey: ['todos'] }) + }, + }) + + return ( + + ) +} +``` + +### Mutation State + +```tsx +const { + mutate, // Fire-and-forget + mutateAsync, // Returns promise + isPending, // Mutation in progress + isError, + isSuccess, + isIdle, // Not yet fired + data, // Success response + error, // Error object + reset, // Reset state to idle + variables, // Variables passed to mutate + status, // 'idle' | 'pending' | 'error' | 'success' +} = useMutation({ ... }) +``` + +## Optimistic Updates + +```tsx +const mutation = useMutation({ + mutationFn: updateTodo, + onMutate: async (newTodo) => { + // 1. Cancel outgoing refetches + await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] }) + + // 2. Snapshot previous value + const previousTodo = queryClient.getQueryData(['todos', newTodo.id]) + + // 3. Optimistically update + queryClient.setQueryData(['todos', newTodo.id], newTodo) + + // 4. Return context for rollback + return { previousTodo } + }, + onError: (err, newTodo, context) => { + // Rollback on error + queryClient.setQueryData(['todos', newTodo.id], context.previousTodo) + }, + onSettled: () => { + // Always refetch to sync with server + queryClient.invalidateQueries({ queryKey: ['todos'] }) + }, +}) +``` + +### Optimistic Updates on Lists + +```tsx +onMutate: async (newTodo) => { + await queryClient.cancelQueries({ queryKey: ['todos'] }) + const previousTodos = queryClient.getQueryData(['todos']) + + queryClient.setQueryData(['todos'], (old) => [...old, newTodo]) + + return { previousTodos } +}, +onError: (err, newTodo, context) => { + queryClient.setQueryData(['todos'], context.previousTodos) +}, +``` + +## Query Invalidation + +```tsx +const queryClient = useQueryClient() + +// Invalidate all queries +queryClient.invalidateQueries() + +// Invalidate by prefix +queryClient.invalidateQueries({ queryKey: ['todos'] }) + +// Invalidate exact match +queryClient.invalidateQueries({ queryKey: ['todos', 1], exact: true }) + +// Invalidate with predicate +queryClient.invalidateQueries({ + predicate: (query) => + query.queryKey[0] === 'todos' && query.queryKey[1]?.status === 'done', +}) + +// Invalidate and refetch immediately +queryClient.refetchQueries({ queryKey: ['todos'] }) + +// Remove from cache entirely +queryClient.removeQueries({ queryKey: ['todos', 1] }) + +// Reset to initial state +queryClient.resetQueries({ queryKey: ['todos'] }) +``` + +## Infinite Queries + +```tsx +import { useInfiniteQuery } from '@tanstack/react-query' + +function InfiniteList() { + const { + data, + fetchNextPage, + fetchPreviousPage, + hasNextPage, + hasPreviousPage, + isFetchingNextPage, + isFetchingPreviousPage, + } = useInfiniteQuery({ + queryKey: ['projects'], + queryFn: async ({ pageParam }) => { + const res = await fetch(`/api/projects?cursor=${pageParam}`) + return res.json() + }, + initialPageParam: 0, + getNextPageParam: (lastPage, allPages, lastPageParam) => { + return lastPage.nextCursor ?? undefined // undefined = no more pages + }, + getPreviousPageParam: (firstPage, allPages, firstPageParam) => { + return firstPage.prevCursor ?? undefined + }, + maxPages: 3, // Keep max 3 pages in cache (for performance) + }) + + return ( +
+ {data.pages.map((page) => + page.items.map((item) => ) + )} + +
+ ) +} +``` + +## Parallel Queries + +```tsx +// Multiple independent queries run in parallel automatically +function Dashboard() { + const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers }) + const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: fetchProjects }) + + // Both fetch simultaneously +} + +// Dynamic parallel queries with useQueries +function UserProjects({ userIds }) { + const queries = useQueries({ + queries: userIds.map((id) => ({ + queryKey: ['user', id], + queryFn: () => fetchUser(id), + })), + combine: (results) => ({ + data: results.map(r => r.data), + pending: results.some(r => r.isPending), + }), + }) +} +``` + +## Dependent Queries + +```tsx +// Sequential queries using enabled +function UserPosts({ userId }) { + const userQuery = useQuery({ + queryKey: ['user', userId], + queryFn: () => fetchUser(userId), + }) + + const postsQuery = useQuery({ + queryKey: ['posts', userId], + queryFn: () => fetchPostsByUser(userId), + enabled: !!userQuery.data, // Only run when user is loaded + }) +} +``` + +## Paginated Queries + +```tsx +function PaginatedList() { + const [page, setPage] = useState(1) + + const { data, isPlaceholderData } = useQuery({ + queryKey: ['todos', page], + queryFn: () => fetchTodos(page), + placeholderData: (previousData) => previousData, // Keep showing old data + }) + + return ( +
+ {data.items.map(item => )} + +
+ ) +} +``` + +## Suspense Integration + +```tsx +import { useSuspenseQuery, useSuspenseInfiniteQuery } from '@tanstack/react-query' + +// Component will suspend until data is loaded +function TodoList() { + const { data } = useSuspenseQuery({ + queryKey: ['todos'], + queryFn: fetchTodos, + }) + // data is guaranteed to be defined here + return
    {data.map(todo =>
  • {todo.title}
  • )}
+} + +// Wrap with Suspense boundary +function App() { + return ( + }> + }> + + + + ) +} + +// Multiple suspense queries (fetch in parallel) +function Dashboard() { + const [{ data: users }, { data: projects }] = useSuspenseQueries({ + queries: [ + { queryKey: ['users'], queryFn: fetchUsers }, + { queryKey: ['projects'], queryFn: fetchProjects }, + ], + }) +} +``` + +## Prefetching + +```tsx +const queryClient = useQueryClient() + +// Prefetch on hover +function TodoLink({ todoId }) { + const prefetch = () => { + queryClient.prefetchQuery({ + queryKey: ['todo', todoId], + queryFn: () => fetchTodo(todoId), + staleTime: 5000, // Only prefetch if data older than 5s + }) + } + + return ( + + Todo {todoId} + + ) +} + +// Prefetch in route loader (TanStack Router integration) +export const Route = createFileRoute('/todos/$todoId')({ + loader: ({ context: { queryClient }, params: { todoId } }) => + queryClient.ensureQueryData(todoQueryOptions(todoId)), +}) + +// Prefetch infinite queries +queryClient.prefetchInfiniteQuery({ + queryKey: ['projects'], + queryFn: fetchProjects, + initialPageParam: 0, + pages: 3, // Prefetch first 3 pages +}) +``` + +## SSR & Hydration + +### Server-Side Prefetching + +```tsx +// Server component or loader +import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query' + +async function getServerSideProps() { + const queryClient = new QueryClient() + + await queryClient.prefetchQuery({ + queryKey: ['todos'], + queryFn: fetchTodos, + }) + + return { + props: { + dehydratedState: dehydrate(queryClient), + }, + } +} + +function Page({ dehydratedState }) { + return ( + + + + ) +} +``` + +### Streaming SSR (React Server Components) + +```tsx +import { dehydrate, HydrationBoundary } from '@tanstack/react-query' +import { makeQueryClient } from './query-client' + +export default async function Page() { + const queryClient = makeQueryClient() + + // Prefetch on server + await queryClient.prefetchQuery({ + queryKey: ['todos'], + queryFn: fetchTodos, + }) + + return ( + + + + ) +} +``` + +## QueryClient API + +```tsx +const queryClient = useQueryClient() + +// Get cached data +queryClient.getQueryData(['todos']) + +// Set cached data +queryClient.setQueryData(['todos'], updatedTodos) +queryClient.setQueryData(['todos'], (old) => [...old, newTodo]) + +// Get query state +queryClient.getQueryState(['todos']) + +// Check if fetching +queryClient.isFetching({ queryKey: ['todos'] }) +queryClient.isMutating() + +// Cancel queries +queryClient.cancelQueries({ queryKey: ['todos'] }) + +// Invalidate (marks stale, refetches active) +queryClient.invalidateQueries({ queryKey: ['todos'] }) + +// Refetch (force refetch even if fresh) +queryClient.refetchQueries({ queryKey: ['todos'] }) + +// Remove from cache +queryClient.removeQueries({ queryKey: ['todos'] }) + +// Reset to initial state +queryClient.resetQueries({ queryKey: ['todos'] }) + +// Clear entire cache +queryClient.clear() + +// Prefetch +queryClient.prefetchQuery({ queryKey: ['todos'], queryFn: fetchTodos }) +queryClient.ensureQueryData({ queryKey: ['todos'], queryFn: fetchTodos }) + +// Get/set defaults +queryClient.setQueryDefaults(['todos'], { staleTime: 10000 }) +queryClient.getQueryDefaults(['todos']) +queryClient.setMutationDefaults(['addTodo'], { mutationFn: addTodo }) +``` + +## Testing + +```tsx +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, // Don't retry in tests + gcTime: Infinity, // Prevent garbage collection during tests + }, + }, + }) + return ({ children }) => ( + + {children} + + ) +} + +test('fetches todos', async () => { + const { result } = renderHook(() => useQuery({ + queryKey: ['todos'], + queryFn: fetchTodos, + }), { wrapper: createWrapper() }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toEqual(expectedTodos) +}) + +// Mock with setQueryData for component tests +test('renders todos', () => { + const queryClient = new QueryClient() + queryClient.setQueryData(['todos'], mockTodos) + + render( + + + + ) + + expect(screen.getByText('Todo 1')).toBeInTheDocument() +}) +``` + +## TypeScript Patterns + +### Typing Query Functions + +```tsx +interface Todo { + id: number + title: string + completed: boolean +} + +// Type is inferred from queryFn return type +const { data } = useQuery({ + queryKey: ['todos'], + queryFn: async (): Promise => { + const res = await fetch('/api/todos') + return res.json() + }, +}) +// data: Todo[] | undefined + +// With select +const { data } = useQuery({ + queryKey: ['todos'], + queryFn: fetchTodos, + select: (data): string[] => data.map(t => t.title), +}) +// data: string[] | undefined +``` + +### Typing Errors + +```tsx +// Default error type is Error +const { error } = useQuery({ + queryKey: ['todos'], + queryFn: fetchTodos, +}) + +// Or register globally +declare module '@tanstack/react-query' { + interface Register { + defaultError: AxiosError + } +} +``` + +### Query Options Pattern (Recommended) + +```tsx +import { queryOptions, infiniteQueryOptions } from '@tanstack/react-query' + +export const todosOptions = queryOptions({ + queryKey: ['todos'] as const, + queryFn: fetchTodos, + staleTime: 5000, +}) + +export const todoOptions = (id: string) => + queryOptions({ + queryKey: ['todos', id] as const, + queryFn: () => fetchTodo(id), + enabled: !!id, + }) + +// Full type inference everywhere +const { data } = useQuery(todosOptions) +const { data } = useSuspenseQuery(todoOptions('123')) +await queryClient.ensureQueryData(todosOptions) +queryClient.invalidateQueries({ queryKey: todosOptions.queryKey }) +``` + +## Advanced Patterns + +### Window Focus Refetching + +```tsx +// Disable globally +const queryClient = new QueryClient({ + defaultOptions: { + queries: { refetchOnWindowFocus: false }, + }, +}) + +// Custom focus manager +import { focusManager } from '@tanstack/react-query' + +// For React Native +focusManager.setEventListener((handleFocus) => { + const subscription = AppState.addEventListener('change', (state) => { + handleFocus(state === 'active') + }) + return () => subscription.remove() +}) +``` + +### Network Mode + +```tsx +useQuery({ + queryKey: ['todos'], + queryFn: fetchTodos, + // 'online' (default): only fetch when online + // 'always': always fetch (useful for local-first) + // 'offlineFirst': try fetch, use cache if offline + networkMode: 'offlineFirst', +}) +``` + +### Query Cancellation + +```tsx +useQuery({ + queryKey: ['todos'], + queryFn: async ({ signal }) => { + // signal is AbortSignal - automatically cancelled on unmount or key change + const res = await fetch('/api/todos', { signal }) + return res.json() + }, +}) + +// Manual cancellation +queryClient.cancelQueries({ queryKey: ['todos'] }) +``` + +### Persistence + +```tsx +import { persistQueryClient } from '@tanstack/react-query-persist-client' +import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister' + +const persister = createSyncStoragePersister({ + storage: window.localStorage, +}) + +persistQueryClient({ + queryClient, + persister, + maxAge: 1000 * 60 * 60 * 24, // 24 hours +}) +``` + +## Best Practices + +1. **Use `queryOptions` helper** for type-safe, reusable query configurations +2. **Structure query keys hierarchically** for granular invalidation +3. **Set appropriate `staleTime`** - 0 means always refetch on mount (default), increase for less dynamic data +4. **Use `placeholderData`** (not `initialData`) for keeping previous page data during pagination +5. **Prefer `useSuspenseQuery`** when using Suspense boundaries for cleaner component code +6. **Use `enabled`** for dependent queries, not conditional hook calls +7. **Always invalidate after mutations** - don't rely solely on optimistic updates +8. **Cancel queries in `onMutate`** before optimistic updates to prevent race conditions +9. **Use `ensureQueryData`** in route loaders instead of `prefetchQuery` for immediate access +10. **Set `retry: false` in tests** to avoid timeout issues +11. **Don't destructure the query result** if you need to pass it around (breaks reactivity) +12. **Use `select`** for derived data instead of transforming in the component +13. **Keep query functions pure** - they should only fetch, not cause side effects +14. **Use `gcTime: Infinity`** in tests to prevent cache cleanup during assertions + +## Common Pitfalls + +- Using `initialData` when you mean `placeholderData` (initialData counts as "fresh" data) +- Not providing `initialPageParam` for infinite queries (required in v5) +- Calling hooks conditionally (violates React rules) +- Not cancelling queries before optimistic updates (race conditions) +- Setting `staleTime` higher than `gcTime` (data gets garbage collected while "fresh") +- Forgetting to wrap tests with `QueryClientProvider` +- Using same `QueryClient` instance across tests (shared state) +- Not awaiting `invalidateQueries` in mutation callbacks when order matters diff --git a/.agents/skills/tanstack-router/SKILL.md b/.agents/skills/tanstack-router/SKILL.md new file mode 100644 index 0000000..a4b817c --- /dev/null +++ b/.agents/skills/tanstack-router/SKILL.md @@ -0,0 +1,734 @@ +--- +name: tanstack-router +description: Type-safe routing for React and Solid applications with first-class search params, data loading, and seamless integration with the React ecosystem. +--- + + +## Overview + +TanStack Router is a fully type-safe router for React (and Solid) applications. It provides file-based routing, first-class search parameter management, built-in data loading, code splitting, and deep TypeScript integration. It serves as the routing foundation for TanStack Start (the full-stack framework). + +**Package:** `@tanstack/react-router` +**CLI:** `@tanstack/router-cli` or `@tanstack/router-plugin` (Vite/Rspack/Webpack) +**Devtools:** `@tanstack/react-router-devtools` + +## Installation + +```bash +npm install @tanstack/react-router +# For file-based routing with Vite: +npm install -D @tanstack/router-plugin +# Or standalone CLI: +npm install -D @tanstack/router-cli +``` + +## Core Concepts + +### Route Trees + +Routes are organized in a tree structure. The root route is the top-level layout, and child routes nest underneath. + +```tsx +import { createRootRoute, createRoute, createRouter } from '@tanstack/react-router' + +const rootRoute = createRootRoute({ + component: RootLayout, +}) + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: HomePage, +}) + +const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + component: AboutPage, +}) + +const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) +const router = createRouter({ routeTree }) +``` + +### File-Based Routing + +File-based routing automatically generates the route tree from your file structure. Configure with Vite plugin: + +```ts +// vite.config.ts +import { defineConfig } from 'vite' +import { TanStackRouterVite } from '@tanstack/router-plugin/vite' + +export default defineConfig({ + plugins: [ + TanStackRouterVite(), + // ... other plugins + ], +}) +``` + +#### File Naming Conventions + +| File Pattern | Route Type | Example Path | +|---|---|---| +| `__root.tsx` | Root layout | N/A (wraps all) | +| `index.tsx` | Index route | `/` | +| `about.tsx` | Static route | `/about` | +| `$postId.tsx` | Dynamic param | `/posts/$postId` | +| `posts.tsx` | Layout route | `/posts/*` (layout) | +| `posts/index.tsx` | Nested index | `/posts` | +| `posts/$postId.tsx` | Nested dynamic | `/posts/123` | +| `posts_.$postId.tsx` | Pathless layout | `/posts/123` (different layout) | +| `_layout.tsx` | Pathless layout | N/A (groups routes) | +| `_layout/dashboard.tsx` | Grouped route | `/dashboard` | +| `$.tsx` | Splat/catch-all | `/*` | +| `posts.$postId.edit.tsx` | Dot notation | `/posts/123/edit` | + +#### Special Prefixes +- `_` prefix: Pathless routes (layout groups without URL segment) +- `$` prefix: Dynamic path parameters +- `(folder)` parentheses: Route groups (organizational, no URL impact) + +### Route Configuration + +Each route can define: + +```tsx +// routes/posts.$postId.tsx +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/posts/$postId')({ + // Validation for path params + params: { + parse: (params) => ({ postId: Number(params.postId) }), + stringify: (params) => ({ postId: String(params.postId) }), + }, + + // Search params validation + validateSearch: (search: Record) => { + return { + page: Number(search.page ?? 1), + filter: (search.filter as string) || '', + } + }, + + // Data loading + loader: async ({ params, context, abortController }) => { + return fetchPost(params.postId) + }, + + // Loader dependencies (re-run loader when these change) + loaderDeps: ({ search }) => ({ page: search.page }), + + // Stale time for cached loader data + staleTime: 5_000, + + // Preloading + preloadStaleTime: 30_000, + + // Error component + errorComponent: PostErrorComponent, + + // Pending/loading component + pendingComponent: PostLoadingComponent, + + // 404 component + notFoundComponent: PostNotFoundComponent, + + // Before load hook (authentication, redirects) + beforeLoad: async ({ context, location }) => { + if (!context.auth.isAuthenticated) { + throw redirect({ + to: '/login', + search: { redirect: location.href }, + }) + } + }, + + // Head/meta management + head: () => ({ + meta: [{ title: 'Post Details' }], + }), + + // Component + component: PostComponent, +}) + +function PostComponent() { + const { postId } = Route.useParams() + const post = Route.useLoaderData() + const { page, filter } = Route.useSearch() + + return
{post.title}
+} +``` + +## Data Loading + +### Route Loaders + +```tsx +export const Route = createFileRoute('/posts')({ + loader: async ({ context }) => { + // Access router context (e.g., queryClient) + const posts = await context.queryClient.ensureQueryData({ + queryKey: ['posts'], + queryFn: fetchPosts, + }) + return { posts } + }, + component: PostsComponent, +}) + +function PostsComponent() { + const { posts } = Route.useLoaderData() + // ... +} +``` + +### Loader Dependencies + +Control when loaders re-execute: + +```tsx +export const Route = createFileRoute('/posts')({ + loaderDeps: ({ search: { page, filter } }) => ({ page, filter }), + loader: async ({ deps: { page, filter } }) => { + return fetchPosts({ page, filter }) + }, +}) +``` + +### Deferred Data Loading + +Stream non-critical data: + +```tsx +import { Await, defer } from '@tanstack/react-router' + +export const Route = createFileRoute('/dashboard')({ + loader: async () => { + const criticalData = await fetchCriticalData() + const deferredData = defer(fetchSlowData()) + return { criticalData, deferredData } + }, + component: DashboardComponent, +}) + +function DashboardComponent() { + const { criticalData, deferredData } = Route.useLoaderData() + + return ( +
+ + }> + + {(data) => } + + +
+ ) +} +``` + +### Context-Based Data Loading + +Provide shared dependencies via router context: + +```tsx +// Create router with context +const router = createRouter({ + routeTree, + context: { + queryClient, + auth: undefined!, // Will be provided by RouterProvider + }, +}) + +// In root/app component +function App() { + const auth = useAuth() + return +} + +// In routes +export const Route = createFileRoute('/protected')({ + beforeLoad: ({ context }) => { + if (!context.auth.user) throw redirect({ to: '/login' }) + }, + loader: ({ context }) => { + return context.queryClient.ensureQueryData(userQueryOptions()) + }, +}) +``` + +## Search Parameters + +### Validation + +```tsx +import { z } from 'zod' + +const postSearchSchema = z.object({ + page: z.number().default(1), + filter: z.string().default(''), + sort: z.enum(['date', 'title']).default('date'), +}) + +export const Route = createFileRoute('/posts')({ + validateSearch: postSearchSchema, + // Or manual validation: + // validateSearch: (search) => postSearchSchema.parse(search), +}) +``` + +### Reading Search Params + +```tsx +function PostsComponent() { + // From route + const { page, filter, sort } = Route.useSearch() + + // Or from any component with useSearch hook + const search = useSearch({ from: '/posts' }) +} +``` + +### Updating Search Params + +```tsx +import { useNavigate } from '@tanstack/react-router' + +function Pagination() { + const navigate = useNavigate() + const { page } = Route.useSearch() + + return ( + + ) +} + +// Or via Link component + ({ ...prev, page: 2 })} +> + Page 2 + +``` + +### Search Param Options + +```tsx +const router = createRouter({ + routeTree, + // Custom serialization + search: { + strict: true, // Reject unknown params + }, + // Default search param serializer + stringifySearch: defaultStringifySearch, + parseSearch: defaultParseSearch, +}) +``` + +## Navigation + +### Link Component + +```tsx +import { Link } from '@tanstack/react-router' + +// Static route +About + +// Dynamic route with params + + Post 123 + + +// With search params + + Page 2 + + +// Active link styling + + Posts + + +// Preloading +Posts +Dashboard + +// Hash +API Reference +``` + +### Programmatic Navigation + +```tsx +import { useNavigate, useRouter } from '@tanstack/react-router' + +function MyComponent() { + const navigate = useNavigate() + const router = useRouter() + + // Navigate to a route + navigate({ to: '/posts', search: { page: 1 } }) + + // Navigate with replace + navigate({ to: '/posts', replace: true }) + + // Relative navigation + navigate({ to: '.', search: (prev) => ({ ...prev, page: 2 }) }) + + // Go back/forward + router.history.back() + router.history.forward() + + // Invalidate and reload current route + router.invalidate() +} +``` + +### Redirects + +```tsx +import { redirect } from '@tanstack/react-router' + +// In beforeLoad or loader +throw redirect({ + to: '/login', + search: { redirect: location.href }, + // Optional status code + statusCode: 301, // Permanent redirect (SSR) +}) +``` + +### Navigation Blocking + +```tsx +import { useBlocker } from '@tanstack/react-router' + +function FormComponent() { + const [isDirty, setIsDirty] = useState(false) + + useBlocker({ + shouldBlockFn: () => isDirty, + withResolver: true, // Shows confirm dialog + }) + + // Or with custom UI + const { proceed, reset, status } = useBlocker({ + shouldBlockFn: () => isDirty, + }) + + if (status === 'blocked') { + return ( +
+

Are you sure you want to leave?

+ + +
+ ) + } +} +``` + +## Code Splitting + +### Automatic (File-Based Routing) + +With file-based routing, create a lazy file: + +``` +routes/ + posts.tsx # Critical: loader, beforeLoad, meta + posts.lazy.tsx # Lazy: component, pendingComponent, errorComponent +``` + +```tsx +// posts.tsx (loaded eagerly) +export const Route = createFileRoute('/posts')({ + loader: () => fetchPosts(), +}) + +// posts.lazy.tsx (loaded lazily) +import { createLazyFileRoute } from '@tanstack/react-router' + +export const Route = createLazyFileRoute('/posts')({ + component: PostsComponent, + pendingComponent: PostsLoading, + errorComponent: PostsError, +}) +``` + +### Manual Code Splitting + +```tsx +const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + loader: () => fetchPosts(), +}).lazy(() => import('./posts.lazy').then((d) => d.Route)) +``` + +## Preloading + +```tsx +// Router-level defaults +const router = createRouter({ + routeTree, + defaultPreload: 'intent', // 'intent' | 'viewport' | 'render' | false + defaultPreloadStaleTime: 30_000, // 30 seconds +}) + +// Route-level +export const Route = createFileRoute('/posts/$postId')({ + // Stale time for the loader data + staleTime: 5_000, + // How long preloaded data stays fresh + preloadStaleTime: 30_000, +}) + +// Link-level + + Posts + +``` + +## Type Safety + +### Register Router Type + +```tsx +// Declare module for type inference +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} +``` + +### Type-Safe Hooks + +All hooks are fully typed based on the route tree: + +```tsx +// useParams - typed to route's params +const { postId } = useParams({ from: '/posts/$postId' }) + +// useSearch - typed to route's search schema +const { page } = useSearch({ from: '/posts' }) + +// useLoaderData - typed to loader return +const data = useLoaderData({ from: '/posts/$postId' }) + +// useRouteContext - typed to route context +const { auth } = useRouteContext({ from: '/protected' }) +``` + +### Route Generics + +```tsx +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/posts/$postId')({ + // TypeScript infers: + // params: { postId: string } + // search: validated search schema type + // loaderData: return type of loader + // context: router context type +}) +``` + +## Authenticated Routes + +```tsx +// __root.tsx +export const Route = createRootRouteWithContext<{ + auth: AuthContext +}>()({ + component: RootComponent, +}) + +// _authenticated.tsx (pathless layout for auth) +export const Route = createFileRoute('/_authenticated')({ + beforeLoad: ({ context, location }) => { + if (!context.auth.isAuthenticated) { + throw redirect({ + to: '/login', + search: { redirect: location.href }, + }) + } + }, +}) + +// _authenticated/dashboard.tsx +export const Route = createFileRoute('/_authenticated/dashboard')({ + component: Dashboard, // Only accessible when authenticated +}) +``` + +## Scroll Restoration + +```tsx +const router = createRouter({ + routeTree, + // Enable scroll restoration + defaultScrollRestoration: true, +}) + +// Or per-route +export const Route = createFileRoute('/posts')({ + // Scroll to top on navigation + scrollRestoration: true, +}) + +// Custom scroll restoration key + location.pathname} +/> +``` + +## Route Masking + +Display a different URL than the actual route: + +```tsx + + View Photo + + +// Or programmatically +navigate({ + to: '/photos/$photoId', + params: { photoId: photo.id }, + mask: { to: '/photos', search: { photoId: photo.id } }, +}) +``` + +## Not Found Handling + +```tsx +// Global 404 +const router = createRouter({ + routeTree, + defaultNotFoundComponent: () =>
Page not found
, +}) + +// Route-level 404 +export const Route = createFileRoute('/posts/$postId')({ + loader: async ({ params }) => { + const post = await fetchPost(params.postId) + if (!post) throw notFound() + return post + }, + notFoundComponent: () =>
Post not found
, +}) +``` + +## Head Management + +```tsx +export const Route = createFileRoute('/posts/$postId')({ + head: ({ loaderData }) => ({ + meta: [ + { title: loaderData.title }, + { name: 'description', content: loaderData.excerpt }, + { property: 'og:title', content: loaderData.title }, + ], + links: [ + { rel: 'canonical', href: `https://example.com/posts/${loaderData.id}` }, + ], + }), +}) +``` + +## Integration with TanStack Query + +```tsx +import { queryOptions } from '@tanstack/react-query' + +const postsQueryOptions = queryOptions({ + queryKey: ['posts'], + queryFn: fetchPosts, +}) + +export const Route = createFileRoute('/posts')({ + loader: ({ context: { queryClient } }) => { + // Ensure data is in cache, won't refetch if fresh + return queryClient.ensureQueryData(postsQueryOptions) + }, + component: PostsComponent, +}) + +function PostsComponent() { + // Use the same query options for reactive updates + const { data: posts } = useSuspenseQuery(postsQueryOptions) + return +} +``` + +## Router Hooks Reference + +| Hook | Purpose | +|------|---------| +| `useRouter()` | Access router instance | +| `useRouterState()` | Subscribe to router state | +| `useParams()` | Get route path params | +| `useSearch()` | Get validated search params | +| `useLoaderData()` | Get route loader data | +| `useRouteContext()` | Get route context | +| `useNavigate()` | Get navigate function | +| `useLocation()` | Get current location | +| `useMatches()` | Get all matched routes | +| `useMatch()` | Get specific route match | +| `useBlocker()` | Block navigation | +| `useLinkProps()` | Get link props for custom components | +| `useMatchRoute()` | Check if a route matches | + +## Best Practices + +1. **Use file-based routing** for most applications - it's simpler and auto-generates the route tree +2. **Validate search params** with Zod or custom validators for type safety +3. **Use `loaderDeps`** to control when loaders re-execute based on search param changes +4. **Leverage context** for dependency injection (QueryClient, auth state) +5. **Use `beforeLoad`** for authentication guards, not in components +6. **Separate critical vs lazy code** - keep loaders in the main file, components in `.lazy.tsx` +7. **Use `preload="intent"`** on Links for perceived performance +8. **Use `staleTime`** to prevent unnecessary refetches during navigation +9. **Register the router type** for full TypeScript inference across the app +10. **Use `notFound()`** instead of conditional rendering for 404 states +11. **Colocate search param logic** with routes that own them +12. **Use pathless layouts** (`_authenticated`) for shared auth/layout logic without URL segments + +## Common Pitfalls + +- Forgetting to register the router type (`declare module`) +- Not using `loaderDeps` when loader depends on search params (causes stale data) +- Putting auth checks in components instead of `beforeLoad` (flash of protected content) +- Not handling the loading state with `pendingComponent` +- Using `useEffect` for data fetching instead of route loaders +- Mutating search params directly instead of using navigate/Link +- Not wrapping the app with `RouterProvider` +- Forgetting `getParentRoute` in code-based route definitions diff --git a/.agents/skills/tanstack-start/SKILL.md b/.agents/skills/tanstack-start/SKILL.md new file mode 100644 index 0000000..2d34fab --- /dev/null +++ b/.agents/skills/tanstack-start/SKILL.md @@ -0,0 +1,250 @@ +--- +name: tanstack-start +description: Full-stack React framework powered by TanStack Router with SSR, streaming, server functions, and deployment to any hosting provider. +--- + +# TanStack Start Skills + +## Overview + +TanStack Start is a full-stack React framework built on TanStack Router, powered by Vite and Nitro (via Vinxi). It provides server-side rendering, streaming, server functions (RPC), middleware, API routes, and deploys to any platform via Nitro presets. + +**Package:** `@tanstack/react-start` +**Router Plugin:** `@tanstack/router-plugin` +**Build Tool:** Vinxi (Vite + Nitro) +**Status:** RC (Release Candidate) +**RSC Support:** React Server Components support is in active development and will land as a non-breaking v1.x addition + +## Installation & Project Setup + +```bash +npx @tanstack/cli create my-app +# Or manually: +npm install @tanstack/react-start @tanstack/react-router react react-dom +npm install -D @tanstack/router-plugin typescript vite vite-tsconfig-paths +``` + +### Project Structure + +``` +my-app/ + app/ + routes/ + __root.tsx # Root layout + index.tsx # / route + posts.$postId.tsx # /posts/:postId + api/ + users.ts # /api/users API route + client.tsx # Client entry + router.tsx # Router creation + ssr.tsx # SSR entry + routeTree.gen.ts # Auto-generated route tree + app.config.ts # TanStack Start config + tsconfig.json + package.json +``` + +### Configuration (`app.config.ts`) + +```typescript +import { defineConfig } from '@tanstack/react-start/config' +import viteTsConfigPaths from 'vite-tsconfig-paths' + +export default defineConfig({ + vite: { + plugins: [ + viteTsConfigPaths({ projects: ['./tsconfig.json'] }), + ], + }, + server: { + preset: 'node-server', // 'vercel' | 'netlify' | 'cloudflare-pages' | etc. + }, + tsr: { + appDirectory: './app', + routesDirectory: './app/routes', + generatedRouteTree: './app/routeTree.gen.ts', + }, +}) +``` + +## Server Functions (`createServerFn`) + +Server functions provide type-safe RPC calls between client and server. + +### Basic Server Functions + +```typescript +import { createServerFn } from '@tanstack/react-start' + +// GET (data fetching, cacheable) +const getUsers = createServerFn() + .handler(async () => { + const users = await db.query.users.findMany() + return users + }) + +// POST (mutations, side effects) +const createUser = createServerFn({ method: 'POST' }) + .validator((data: { name: string; email: string }) => data) + .handler(async ({ data }) => { + const user = await db.insert(users).values(data).returning() + return user + }) +``` + +### With Zod Validation + +```typescript +import { z } from 'zod' + +const updateUser = createServerFn({ method: 'POST' }) + .validator( + z.object({ + id: z.string(), + name: z.string().min(1), + email: z.string().email(), + }) + ) + .handler(async ({ data }) => { + // data is fully typed: { id: string; name: string; email: string } + return await db.update(users).set(data).where(eq(users.id, data.id)) + }) +``` + +## Middleware + +### Creating Middleware + +```typescript +import { createMiddleware } from '@tanstack/react-start' + +const loggingMiddleware = createMiddleware().handler(async ({ next }) => { + console.log('Request started') + const result = await next() + console.log('Request completed') + return result +}) +``` + +### Auth Middleware with Context + +```typescript +const authMiddleware = createMiddleware().handler(async ({ next }) => { + const request = getWebRequest() + const session = await getSession(request) + + if (!session?.user) { + throw redirect({ to: '/login' }) + } + + // Pass typed context to handler + return next({ context: { user: session.user } }) +}) +``` + +### Chaining Middleware + +```typescript +const adminMiddleware = createMiddleware() + .middleware([authMiddleware]) + .handler(async ({ next, context }) => { + // context.user is typed from authMiddleware + if (context.user.role !== 'admin') { + throw redirect({ to: '/unauthorized' }) + } + return next({ context: { isAdmin: true } }) + }) + +// Usage +const adminAction = createServerFn({ method: 'POST' }) + .middleware([adminMiddleware]) + .handler(async ({ context }) => { + // context: { user: User; isAdmin: boolean } + return { success: true } + }) +``` + +## API Routes (Server Routes) + +```typescript +// app/routes/api/users.ts +import { createAPIFileRoute } from '@tanstack/react-start/api' + +export const APIRoute = createAPIFileRoute('/api/users')({ + GET: async ({ request }) => { + const users = await db.query.users.findMany() + return Response.json(users) + }, + POST: async ({ request }) => { + const body = await request.json() + const user = await db.insert(users).values(body).returning() + return new Response(JSON.stringify(user), { status: 201 }) + }, +}) +``` + +## SSR Strategies + +### Streaming SSR (Default) + +```typescript +export const Route = createFileRoute('/dashboard')({ + loader: async () => ({ + criticalData: await fetchCriticalData(), + deferredData: defer(fetchSlowData()), + }), + component: Dashboard, +}) + +function Dashboard() { + const { criticalData, deferredData } = Route.useLoaderData() + return ( +
+ + }> + + {(data) => } + + +
+ ) +} +``` + +## Deployment + +### Supported Platforms (Nitro Presets) + +```typescript +// app.config.ts +export default defineConfig({ + server: { + preset: 'node-server', // Self-hosted Node.js + // preset: 'vercel', // Vercel + // preset: 'netlify', // Netlify + // preset: 'cloudflare-pages', // Cloudflare Pages + // preset: 'aws-lambda', // AWS Lambda + // preset: 'deno-server', // Deno Deploy + // preset: 'bun', // Bun + }, +}) +``` + +## Best Practices + +1. **Use validators for all server function inputs** - runtime safety and TypeScript inference +2. **Compose middleware** for cross-cutting concerns (auth, logging, rate limiting) +3. **Use `createServerFn` GET** for data fetching (cacheable, preloadable) +4. **Use `createServerFn` POST** for mutations and side effects +5. **Use `beforeLoad`** for route-level auth guards +6. **Use `defer()`** for non-critical data to improve TTFB +7. **Set `defaultPreload: 'intent'`** on the router for instant navigation +8. **Co-locate server functions** with the routes that use them + +## Common Pitfalls + +- Server functions cannot close over client-side variables (they're extracted to separate bundles) +- Data returned from server functions must be serializable +- Forgetting `await` in loaders leads to streaming issues +- Importing server-only code in client bundles causes build errors +- Missing `declare module '@tanstack/react-router'` loses all type safety diff --git a/.agents/skills/tanstack-store/SKILL.md b/.agents/skills/tanstack-store/SKILL.md new file mode 100644 index 0000000..c1e289b --- /dev/null +++ b/.agents/skills/tanstack-store/SKILL.md @@ -0,0 +1,305 @@ +--- +name: tanstack-store +description: Framework-agnostic, immutable reactive data store with framework adapters for React, Vue, Solid, Angular, and Svelte. +--- + + +## Overview + +TanStack Store is a lightweight reactive store (signals-like) that powers the internals of TanStack libraries. It provides `Store` for state, `Derived` for computed values, `Effect` for side effects, and `batch` for atomic updates. Framework adapters provide reactive hooks. + +**Core:** `@tanstack/store` +**React:** `@tanstack/react-store` + +## Installation + +```bash +npm install @tanstack/store @tanstack/react-store +``` + +## Store + +### Creating a Store + +```typescript +import { Store } from '@tanstack/store' + +const countStore = new Store(0) + +const userStore = new Store<{ name: string; email: string }>({ + name: 'Alice', + email: 'alice@example.com', +}) +``` + +### Updating State + +```typescript +// Function updater (immutable update) +countStore.setState((prev) => prev + 1) + +userStore.setState((prev) => ({ ...prev, name: 'Bob' })) +``` + +### Subscribing to Changes + +```typescript +const unsub = countStore.subscribe(() => { + console.log('Count:', countStore.state) +}) + +// Cleanup +unsub() +``` + +### Store Options + +```typescript +const store = new Store(initialState, { + // Custom update function + updateFn: (prevValue) => (updater) => { + return updater(prevValue) // custom logic + }, + // Callback on subscribe + onSubscribe: (listener, store) => { + console.log('New subscriber') + return () => console.log('Unsubscribed') + }, + // Callback on every update + onUpdate: () => { + console.log('State updated:', store.state) + }, +}) +``` + +### Store Properties + +```typescript +store.state // Current state +store.prevState // Previous state +store.listeners // Set of listener callbacks +``` + +## Derived (Computed Values) + +```typescript +import { Store, Derived } from '@tanstack/store' + +const count = new Store(5) +const multiplier = new Store(2) + +const doubled = new Derived({ + deps: [count, multiplier], + fn: ({ currDepVals }) => currDepVals[0] * currDepVals[1], +}) + +// MUST mount to activate +const unmount = doubled.mount() + +console.log(doubled.state) // 10 + +count.setState(() => 10) +console.log(doubled.state) // 20 + +// Cleanup +unmount() +``` + +### Derived with Previous Value + +```typescript +const accumulated = new Derived({ + deps: [count], + fn: ({ prevVal, currDepVals }) => { + return currDepVals[0] + (prevVal ?? 0) + }, +}) +``` + +### Chaining Derived + +```typescript +const filtered = new Derived({ + deps: [dataStore, filterStore], + fn: ({ currDepVals }) => currDepVals[0].filter(matchesFilter(currDepVals[1])), +}) + +const sorted = new Derived({ + deps: [filtered, sortStore], + fn: ({ currDepVals }) => [...currDepVals[0]].sort(comparator(currDepVals[1])), +}) + +const paginated = new Derived({ + deps: [sorted, pageStore], + fn: ({ currDepVals }) => currDepVals[0].slice( + currDepVals[1].offset, + currDepVals[1].offset + currDepVals[1].limit, + ), +}) +``` + +## Effect (Side Effects) + +```typescript +import { Store, Effect } from '@tanstack/store' + +const count = new Store(0) + +const logger = new Effect({ + deps: [count], + fn: () => { + console.log('Count changed:', count.state) + // Optionally return cleanup function + return () => console.log('Cleaning up') + }, + eager: false, // true = run immediately on mount +}) + +const unmount = logger.mount() + +count.setState(() => 1) // logs: "Count changed: 1" + +unmount() +``` + +### Effect with Cleanup + +```typescript +const timerEffect = new Effect({ + deps: [intervalStore], + fn: () => { + const id = setInterval(() => { /* ... */ }, intervalStore.state) + return () => clearInterval(id) // cleanup on next run or unmount + }, +}) +``` + +## Batch + +Group multiple updates into one notification: + +```typescript +import { batch } from '@tanstack/store' + +// Subscribers fire only once with final state +batch(() => { + countStore.setState(() => 1) + nameStore.setState(() => 'Alice') + settingsStore.setState((prev) => ({ ...prev, theme: 'dark' })) +}) +``` + +## React Integration + +### useStore Hook + +```tsx +import { useStore } from '@tanstack/react-store' + +// Subscribe to full state +function Counter() { + const count = useStore(countStore) + return +} + +// Subscribe with selector (performance optimization) +function UserName() { + const name = useStore(userStore, (state) => state.name) + return {name} +} + +// Subscribe to Derived +function DoubledDisplay() { + const value = useStore(doubledDerived) + return {value} +} +``` + +### shallow Equality Function + +Prevents re-renders when selector returns structurally-equal objects: + +```tsx +import { useStore } from '@tanstack/react-store' +import { shallow } from '@tanstack/react-store' + +function TodoList() { + // Without shallow: re-renders on ANY state change (new object ref) + // With shallow: only re-renders when items actually change + const items = useStore(todosStore, (state) => state.items, shallow) + return
    {items.map(/* ... */)}
+} +``` + +### Mounting Derived/Effect in React + +```tsx +function MyComponent() { + useEffect(() => { + const unmountDerived = myDerived.mount() + const unmountEffect = myEffect.mount() + return () => { + unmountDerived() + unmountEffect() + } + }, []) + + const value = useStore(myDerived) + return {value} +} +``` + +## Module-Level Store Pattern + +```typescript +// stores/counter.ts +import { Store, Derived } from '@tanstack/store' + +export const counterStore = new Store(0) + +export const doubledCount = new Derived({ + deps: [counterStore], + fn: ({ currDepVals }) => currDepVals[0] * 2, +}) + +// Actions as plain functions +export function increment() { + counterStore.setState((c) => c + 1) +} + +export function reset() { + counterStore.setState(() => 0) +} +``` + +## Framework Adapters + +| Framework | Package | Hook/Composable | +|-----------|---------|-----------------| +| React | `@tanstack/react-store` | `useStore(store, selector?, equalityFn?)` | +| Vue | `@tanstack/vue-store` | `useStore(store, selector?)` (returns computed ref) | +| Solid | `@tanstack/solid-store` | `useStore(store, selector?)` (returns signal) | +| Angular | `@tanstack/angular-store` | `injectStore(store, selector?)` (returns signal) | +| Svelte | `@tanstack/svelte-store` | `useStore(store, selector?)` (returns $state) | + +## Best Practices + +1. **Define stores at module level** - they're singletons +2. **Use selectors** in `useStore` to prevent unnecessary re-renders +3. **Use `shallow`** when selectors return objects/arrays +4. **Always call `mount()`** on Derived and Effect instances +5. **Always clean up** unmount functions (especially in React useEffect) +6. **Never mutate state directly** - always use `setState` +7. **Use `batch`** for multiple related updates +8. **Use Derived chains** for data transformations (filter -> sort -> paginate) +9. **Return cleanup functions** from Effect `fn` for timers/listeners +10. **Select primitives** when possible (no equality fn needed) + +## Common Pitfalls + +- Forgetting to `mount()` Derived/Effect (they won't activate) +- Not cleaning up subscriptions/unmount functions (memory leaks) +- Mutating `store.state` directly instead of using `setState` +- Creating new object references in selectors without `shallow` +- Using `useStore` without a selector (subscribes to everything) +- Forgetting `eager: true` when Effect should run immediately diff --git a/.agents/skills/tanstack-table/SKILL.md b/.agents/skills/tanstack-table/SKILL.md new file mode 100644 index 0000000..3159adb --- /dev/null +++ b/.agents/skills/tanstack-table/SKILL.md @@ -0,0 +1,582 @@ +--- +name: tanstack-table +description: Headless UI for building powerful tables & datagrids for TS/JS, React, Vue, Solid, Svelte, Qwik, Angular, and Lit. +--- + + +## Overview + +TanStack Table is a headless UI library for building data tables and datagrids. It provides logic for sorting, filtering, pagination, grouping, expanding, column pinning/ordering/visibility/resizing, and row selection - without rendering any markup or styles. + +**Package:** `@tanstack/react-table` +**Utilities:** `@tanstack/match-sorter-utils` (fuzzy filtering) +**Current Version:** v8 + +## Installation + +```bash +npm install @tanstack/react-table +``` + +## Core Architecture + +### Building Blocks + +1. **Column Definitions** - describe columns (data access, rendering, features) +2. **Table Instance** - central coordinator with state and APIs +3. **Row Models** - data processing pipeline (filter -> sort -> group -> paginate) +4. **Headers, Rows, Cells** - renderable units + +### Critical: Data & Column Stability + +```typescript +// WRONG - new references every render, causes infinite loops +const table = useReactTable({ + data: fetchedData.results, // new ref! + columns: [{ accessorKey: 'name' }], // new ref! +}) + +// CORRECT - stable references +const columns = useMemo(() => [...], []) +const data = useMemo(() => fetchedData?.results ?? [], [fetchedData]) + +const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() }) +``` + +## Column Definitions + +### Using createColumnHelper (Recommended) + +```typescript +import { createColumnHelper } from '@tanstack/react-table' + +type Person = { + firstName: string + lastName: string + age: number + status: 'active' | 'inactive' +} + +const columnHelper = createColumnHelper() + +const columns = [ + // Accessor column (data column) + columnHelper.accessor('firstName', { + header: 'First Name', + cell: info => info.getValue(), + footer: info => info.column.id, + }), + + // Accessor with function + columnHelper.accessor(row => row.lastName, { + id: 'lastName', // required with accessorFn + header: () => Last Name, + cell: info => {info.getValue()}, + }), + + // Display column (no data, custom rendering) + columnHelper.display({ + id: 'actions', + header: 'Actions', + cell: ({ row }) => ( + + ), + }), + + // Group column (nested headers) + columnHelper.group({ + id: 'info', + header: 'Info', + columns: [ + columnHelper.accessor('age', { header: 'Age' }), + columnHelper.accessor('status', { header: 'Status' }), + ], + }), +] +``` + +### Column Options + +| Option | Type | Description | +|--------|------|-------------| +| `id` | `string` | Unique identifier (auto-derived from accessorKey) | +| `accessorKey` | `string` | Dot-notation path to row data | +| `accessorFn` | `(row) => any` | Custom accessor function | +| `header` | `string \| (context) => ReactNode` | Header renderer | +| `cell` | `(context) => ReactNode` | Cell renderer | +| `footer` | `(context) => ReactNode` | Footer renderer | +| `size` | `number` | Default width (default: 150) | +| `minSize` | `number` | Min width (default: 20) | +| `maxSize` | `number` | Max width | +| `enableSorting` | `boolean` | Enable sorting | +| `sortingFn` | `string \| SortingFn` | Sort function | +| `enableFiltering` | `boolean` | Enable filtering | +| `filterFn` | `string \| FilterFn` | Filter function | +| `enableGrouping` | `boolean` | Enable grouping | +| `aggregationFn` | `string \| AggregationFn` | Aggregation function | +| `enableHiding` | `boolean` | Enable visibility toggle | +| `enableResizing` | `boolean` | Enable resizing | +| `enablePinning` | `boolean` | Enable pinning | +| `meta` | `any` | Custom metadata | + +## Table Instance + +### Creating a Table + +```typescript +import { + useReactTable, + getCoreRowModel, + getSortedRowModel, + getFilteredRowModel, + getPaginationRowModel, + flexRender, +} from '@tanstack/react-table' + +function MyTable() { + const [sorting, setSorting] = useState([]) + const [columnFilters, setColumnFilters] = useState([]) + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }) + + const table = useReactTable({ + data, + columns, + state: { sorting, columnFilters, pagination }, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }) + + return ( + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + ))} + + ))} + + + {table.getRowModel().rows.map(row => ( + + {row.getVisibleCells().map(cell => ( + + ))} + + ))} + +
+ {header.isPlaceholder ? null : + flexRender(header.column.columnDef.header, header.getContext())} + {{ asc: ' ↑', desc: ' ↓' }[header.column.getIsSorted() as string] ?? null} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ ) +} +``` + +## Sorting + +```typescript +const table = useReactTable({ + state: { sorting }, + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + enableSorting: true, + enableMultiSort: true, + // manualSorting: true, // For server-side sorting +}) + +// Built-in sort functions: 'alphanumeric', 'text', 'datetime', 'basic' +// Column-level: sortingFn: 'alphanumeric' +``` + +## Filtering + +### Column Filtering + +```typescript +const table = useReactTable({ + state: { columnFilters }, + onColumnFiltersChange: setColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + getFacetedMinMaxValues: getFacetedMinMaxValues(), +}) + +// Built-in: 'includesString', 'equalsString', 'arrIncludes', 'inNumberRange', etc. + +// Filter UI +function Filter({ column }) { + return ( + column.setFilterValue(e.target.value)} + placeholder={`Filter... (${column.getFacetedUniqueValues()?.size})`} + /> + ) +} +``` + +### Global Filtering + +```typescript +const [globalFilter, setGlobalFilter] = useState('') + +const table = useReactTable({ + state: { globalFilter }, + onGlobalFilterChange: setGlobalFilter, + globalFilterFn: 'includesString', + getFilteredRowModel: getFilteredRowModel(), +}) +``` + +### Fuzzy Filtering + +```typescript +import { rankItem } from '@tanstack/match-sorter-utils' + +const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + const itemRank = rankItem(row.getValue(columnId), value) + addMeta({ itemRank }) + return itemRank.passed +} + +const table = useReactTable({ + filterFns: { fuzzy: fuzzyFilter }, + globalFilterFn: 'fuzzy', +}) +``` + +## Pagination + +```typescript +const table = useReactTable({ + state: { pagination }, + onPaginationChange: setPagination, + getPaginationRowModel: getPaginationRowModel(), + // For server-side: + // manualPagination: true, + // pageCount: serverPageCount, +}) + +// Navigation +table.nextPage() +table.previousPage() +table.firstPage() +table.lastPage() +table.setPageSize(20) +table.getCanNextPage() // boolean +table.getCanPreviousPage() // boolean +table.getPageCount() // total pages +``` + +## Row Selection + +```typescript +const [rowSelection, setRowSelection] = useState({}) + +const table = useReactTable({ + state: { rowSelection }, + onRowSelectionChange: setRowSelection, + enableRowSelection: true, + enableMultiRowSelection: true, +}) + +// Checkbox column +columnHelper.display({ + id: 'select', + header: ({ table }) => ( + + ), + cell: ({ row }) => ( + + ), +}) + +// Get selected rows +table.getSelectedRowModel().rows +``` + +## Column Visibility + +```typescript +const [columnVisibility, setColumnVisibility] = useState({}) + +const table = useReactTable({ + state: { columnVisibility }, + onColumnVisibilityChange: setColumnVisibility, +}) + +// Toggle UI +{table.getAllLeafColumns().map(column => ( + +))} +``` + +## Column Pinning + +```typescript +const [columnPinning, setColumnPinning] = useState({ + left: ['select', 'name'], + right: ['actions'], +}) + +const table = useReactTable({ + state: { columnPinning }, + onColumnPinningChange: setColumnPinning, + enableColumnPinning: true, +}) + +// Render pinned sections separately +row.getLeftVisibleCells() // Left-pinned +row.getCenterVisibleCells() // Unpinned +row.getRightVisibleCells() // Right-pinned +``` + +## Column Resizing + +```typescript +const table = useReactTable({ + enableColumnResizing: true, + columnResizeMode: 'onChange', // 'onChange' | 'onEnd' + defaultColumn: { size: 150, minSize: 50, maxSize: 500 }, +}) + +// Resize handle in header +
+``` + +## Grouping & Aggregation + +```typescript +const [grouping, setGrouping] = useState([]) + +const table = useReactTable({ + state: { grouping }, + onGroupingChange: setGrouping, + getGroupedRowModel: getGroupedRowModel(), + getExpandedRowModel: getExpandedRowModel(), +}) + +// Built-in aggregation: 'sum', 'min', 'max', 'mean', 'median', 'count', 'unique', 'uniqueCount' +columnHelper.accessor('amount', { + aggregationFn: 'sum', + aggregatedCell: ({ getValue }) => `Total: ${getValue()}`, +}) +``` + +## Row Expanding + +```typescript +const [expanded, setExpanded] = useState({}) + +const table = useReactTable({ + state: { expanded }, + onExpandedChange: setExpanded, + getExpandedRowModel: getExpandedRowModel(), + getSubRows: (row) => row.subRows, // For hierarchical data +}) + +// Expand toggle + + +// Detail row pattern +{row.getIsExpanded() && ( + + + + + +)} +``` + +## Virtualization Integration + +```typescript +import { useVirtualizer } from '@tanstack/react-virtual' + +function VirtualizedTable() { + const table = useReactTable({ /* ... */ }) + const { rows } = table.getRowModel() + const parentRef = useRef(null) + + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 35, + overscan: 10, + }) + + return ( +
+ + + {virtualizer.getVirtualItems().map(virtualRow => { + const row = rows[virtualRow.index] + return ( + + {row.getVisibleCells().map(cell => ( + + ))} + + ) + })} + +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ ) +} +``` + +## Server-Side Operations + +```typescript +const table = useReactTable({ + data: serverData, + columns, + manualSorting: true, + manualFiltering: true, + manualPagination: true, + pageCount: serverPageCount, + state: { sorting, columnFilters, pagination }, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + // Do NOT include getSortedRowModel, getFilteredRowModel, getPaginationRowModel +}) + +// Fetch data based on state +useEffect(() => { + fetchData({ sorting, filters: columnFilters, pagination }) +}, [sorting, columnFilters, pagination]) +``` + +## TypeScript Patterns + +### Extending Column Meta + +```typescript +declare module '@tanstack/react-table' { + interface ColumnMeta { + filterVariant?: 'text' | 'range' | 'select' + align?: 'left' | 'center' | 'right' + } +} +``` + +### Custom Filter/Sort Function Registration + +```typescript +declare module '@tanstack/react-table' { + interface FilterFns { + fuzzy: FilterFn + } + interface SortingFns { + myCustomSort: SortingFn + } +} +``` + +### Editable Cells via Table Meta + +```typescript +declare module '@tanstack/react-table' { + interface TableMeta { + updateData: (rowIndex: number, columnId: string, value: unknown) => void + } +} + +const table = useReactTable({ + meta: { + updateData: (rowIndex, columnId, value) => { + setData(old => old.map((row, i) => + i === rowIndex ? { ...row, [columnId]: value } : row + )) + }, + }, +}) +``` + +## Key Imports + +```typescript +import { + createColumnHelper, flexRender, useReactTable, + getCoreRowModel, getSortedRowModel, getFilteredRowModel, + getPaginationRowModel, getGroupedRowModel, getExpandedRowModel, + getFacetedRowModel, getFacetedUniqueValues, getFacetedMinMaxValues, +} from '@tanstack/react-table' + +import type { + ColumnDef, SortingState, ColumnFiltersState, VisibilityState, + PaginationState, ExpandedState, RowSelectionState, GroupingState, + ColumnOrderState, ColumnPinningState, FilterFn, SortingFn, +} from '@tanstack/react-table' +``` + +## Best Practices + +1. **Always memoize `data` and `columns`** to prevent infinite re-renders +2. **Use `flexRender`** for all header/cell/footer rendering +3. **Use `table.getRowModel().rows`** for final rendered rows (not getCoreRowModel) +4. **Import only needed row models** - each adds processing to the pipeline +5. **Use `getRowId`** for stable row keys when data has unique IDs +6. **Use `manualX` options** for server-side operations +7. **Pair controlled state** with both `state.X` and `onXChange` +8. **Use module augmentation** for custom meta, filter fns, sort fns +9. **Use column helper** for type-safe column definitions +10. **Set `autoResetPageIndex: true`** when filtering should reset pagination + +## Common Pitfalls + +- Defining columns inline (creates new ref each render) +- Forgetting `getCoreRowModel()` (required for all tables) +- Using row models without importing them +- Not providing `id` when using `accessorFn` +- Mixing `manualPagination` with client-side `getPaginationRowModel` +- Forgetting `colSpan` for grouped headers +- Not handling `header.isPlaceholder` for group column spacers diff --git a/.agents/skills/tanstack-virtual/SKILL.md b/.agents/skills/tanstack-virtual/SKILL.md new file mode 100644 index 0000000..ec6401e --- /dev/null +++ b/.agents/skills/tanstack-virtual/SKILL.md @@ -0,0 +1,369 @@ +--- +name: tanstack-virtual +description: Headless UI for virtualizing large element lists at 60FPS in TS/JS, React, Vue, Solid, Svelte, Lit & Angular. +--- + + +## Overview + +TanStack Virtual provides virtualization logic for rendering only visible items in large lists, grids, and tables. It calculates which items are in the viewport and positions them with absolute positioning, keeping DOM node count minimal regardless of dataset size. + +**Package:** `@tanstack/react-virtual` +**Core:** `@tanstack/virtual-core` (framework-agnostic) + +## Installation + +```bash +npm install @tanstack/react-virtual +``` + +## Core Pattern + +```tsx +import { useVirtualizer } from '@tanstack/react-virtual' + +function VirtualList() { + const parentRef = useRef(null) + + const virtualizer = useVirtualizer({ + count: 10000, + getScrollElement: () => parentRef.current, + estimateSize: () => 35, // estimated row height in px + overscan: 5, + }) + + return ( +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => ( +
+ Row {virtualItem.index} +
+ ))} +
+
+ ) +} +``` + +## Virtualizer Options + +### Required + +| Option | Type | Description | +|--------|------|-------------| +| `count` | `number` | Total number of items | +| `getScrollElement` | `() => Element \| null` | Returns scroll container | +| `estimateSize` | `(index) => number` | Estimated item size (overestimate recommended) | + +### Optional + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `overscan` | `number` | `1` | Extra items rendered beyond viewport | +| `horizontal` | `boolean` | `false` | Horizontal virtualization | +| `gap` | `number` | `0` | Gap between items (px) | +| `lanes` | `number` | `1` | Number of lanes (masonry/grid) | +| `paddingStart` | `number` | `0` | Padding before first item | +| `paddingEnd` | `number` | `0` | Padding after last item | +| `scrollPaddingStart` | `number` | `0` | Offset for scrollTo positioning | +| `scrollPaddingEnd` | `number` | `0` | Offset for scrollTo positioning | +| `initialOffset` | `number` | `0` | Starting scroll position | +| `initialRect` | `Rect` | - | Initial dimensions (SSR) | +| `enabled` | `boolean` | `true` | Enable/disable | +| `getItemKey` | `(index) => Key` | `(i) => i` | Stable key for items | +| `rangeExtractor` | `(range) => number[]` | default | Custom visible indices | +| `scrollToFn` | `(offset, options, instance) => void` | default | Custom scroll behavior | +| `measureElement` | `(el, entry, instance) => number` | default | Custom measurement | +| `onChange` | `(instance, sync) => void` | - | State change callback | +| `isScrollingResetDelay` | `number` | `150` | Delay before scroll complete | + +## Virtualizer API + +```typescript +// Get visible items +virtualizer.getVirtualItems(): VirtualItem[] + +// Get total scrollable size +virtualizer.getTotalSize(): number + +// Scroll to specific index +virtualizer.scrollToIndex(index, { align: 'start' | 'center' | 'end' | 'auto', behavior: 'auto' | 'smooth' }) + +// Scroll to offset +virtualizer.scrollToOffset(offset, options) + +// Force recalculation +virtualizer.measure() +``` + +## VirtualItem Properties + +```typescript +interface VirtualItem { + key: Key // Unique key + index: number // Index in source data + start: number // Pixel offset (use for transform) + end: number // End pixel offset + size: number // Item dimension + lane: number // Lane index (multi-column) +} +``` + +## Dynamic/Variable Heights + +Use `measureElement` ref for items with unknown heights: + +```tsx +const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 50, // overestimate +}) + +{virtualizer.getVirtualItems().map((virtualItem) => ( +
+ {items[virtualItem.index].content} +
+))} +``` + +## Horizontal Virtualization + +```tsx +const virtualizer = useVirtualizer({ + count: columns.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 100, + horizontal: true, +}) + +// Use width for container, translateX for positioning +
+ {virtualizer.getVirtualItems().map((item) => ( +
+ Column {item.index} +
+ ))} +
+``` + +## Grid Virtualization (Two Virtualizers) + +```tsx +function VirtualGrid() { + const parentRef = useRef(null) + + const rowVirtualizer = useVirtualizer({ + count: 10000, + getScrollElement: () => parentRef.current, + estimateSize: () => 35, + overscan: 5, + }) + + const columnVirtualizer = useVirtualizer({ + count: 10000, + getScrollElement: () => parentRef.current, + estimateSize: () => 100, + horizontal: true, + overscan: 5, + }) + + return ( +
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => ( + + {columnVirtualizer.getVirtualItems().map((virtualColumn) => ( +
+ Cell {virtualRow.index},{virtualColumn.index} +
+ ))} +
+ ))} +
+
+ ) +} +``` + +## Window Scrolling + +```tsx +import { useWindowVirtualizer } from '@tanstack/react-virtual' + +function WindowList() { + const listRef = useRef(null) + + const virtualizer = useWindowVirtualizer({ + count: 10000, + estimateSize: () => 45, + overscan: 5, + scrollMargin: listRef.current?.offsetTop ?? 0, + }) + + return ( +
+
+ {virtualizer.getVirtualItems().map((item) => ( +
+ Row {item.index} +
+ ))} +
+
+ ) +} +``` + +## Infinite Scrolling + +```tsx +import { useVirtualizer } from '@tanstack/react-virtual' +import { useInfiniteQuery } from '@tanstack/react-query' + +function InfiniteList() { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ + queryKey: ['items'], + queryFn: ({ pageParam = 0 }) => fetchItems(pageParam), + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + + const allItems = data?.pages.flatMap((page) => page.items) ?? [] + + const virtualizer = useVirtualizer({ + count: hasNextPage ? allItems.length + 1 : allItems.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 50, + overscan: 5, + }) + + useEffect(() => { + const items = virtualizer.getVirtualItems() + const lastItem = items[items.length - 1] + if (lastItem && lastItem.index >= allItems.length - 1 && hasNextPage && !isFetchingNextPage) { + fetchNextPage() + } + }, [virtualizer.getVirtualItems(), hasNextPage, isFetchingNextPage, allItems.length]) + + // Render virtual items, show loader row for last item if loading +} +``` + +## Sticky Items + +```tsx +import { defaultRangeExtractor, Range } from '@tanstack/react-virtual' + +const stickyIndexes = [0, 10, 20, 30] // Header indices + +const virtualizer = useVirtualizer({ + count: 1000, + getScrollElement: () => parentRef.current, + estimateSize: () => 50, + rangeExtractor: useCallback((range: Range) => { + const next = new Set([...stickyIndexes, ...defaultRangeExtractor(range)]) + return [...next].sort((a, b) => a - b) + }, [stickyIndexes]), +}) + +// Render sticky items with position: sticky; top: 0; zIndex: 1 +``` + +## Smooth Scrolling + +```tsx +const virtualizer = useVirtualizer({ + scrollToFn: (offset, { behavior }, instance) => { + if (behavior === 'smooth') { + // Custom easing animation + instance.scrollElement?.scrollTo({ top: offset, behavior: 'smooth' }) + } else { + instance.scrollElement?.scrollTo({ top: offset }) + } + }, +}) + +// Usage +virtualizer.scrollToIndex(500, { align: 'center', behavior: 'smooth' }) +``` + +## Best Practices + +1. **Overestimate `estimateSize`** - prevents scroll jumps (items shrinking causes issues) +2. **Increase `overscan`** (3-5) to reduce blank flashing during fast scrolling +3. **Use `transform: translateY()`** over `top` for GPU-composited positioning +4. **Add `data-index` attribute** when using `measureElement` for dynamic sizing +5. **Don't set fixed height** on dynamically measured items +6. **Use `getItemKey`** for stable keys when items can reorder +7. **Use `gap` option** instead of margins (margins interfere with measurement) +8. **Use `paddingStart/End`** instead of CSS padding on the container +9. **Use `enabled: false`** to pause when the list is hidden +10. **Memoize callbacks** (`estimateSize`, `getItemKey`, `rangeExtractor`) +11. **Use `will-change: transform`** CSS on items for GPU acceleration + +## Common Pitfalls + +- Setting fixed height on dynamically measured items +- Using CSS margins instead of the `gap` option +- Forgetting `data-index` with `measureElement` +- Not providing `position: relative` on the inner container +- Underestimating `estimateSize` (causes scroll jumps) +- Setting `overscan` too low for fast scrolling (blank items) +- Forgetting to subtract `scrollMargin` from `translateY` in window scrolling +- Not memoizing the `estimateSize` function (causes re-renders) diff --git a/.agents/skills/web-design-guidelines/SKILL.md b/.agents/skills/web-design-guidelines/SKILL.md new file mode 100644 index 0000000..ceae92a --- /dev/null +++ b/.agents/skills/web-design-guidelines/SKILL.md @@ -0,0 +1,39 @@ +--- +name: web-design-guidelines +description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices". +metadata: + author: vercel + version: "1.0.0" + argument-hint: +--- + +# Web Interface Guidelines + +Review files for compliance with Web Interface Guidelines. + +## How It Works + +1. Fetch the latest guidelines from the source URL below +2. Read the specified files (or prompt user for files/pattern) +3. Check against all rules in the fetched guidelines +4. Output findings in the terse `file:line` format + +## Guidelines Source + +Fetch fresh guidelines before each review: + +``` +https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md +``` + +Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions. + +## Usage + +When a user provides a file or pattern argument: +1. Fetch guidelines from the source URL above +2. Read the specified files +3. Apply all rules from the fetched guidelines +4. Output findings using the format specified in the guidelines + +If no files specified, ask the user which files to review. diff --git a/.claude/skills/better-auth-best-practices/SKILL.md b/.claude/skills/better-auth-best-practices/SKILL.md new file mode 100644 index 0000000..3e6a4e1 --- /dev/null +++ b/.claude/skills/better-auth-best-practices/SKILL.md @@ -0,0 +1,175 @@ +--- +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()`. + +--- + +## 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) \ No newline at end of file diff --git a/.claude/skills/mantine-custom-components/SKILL.md b/.claude/skills/mantine-custom-components/SKILL.md new file mode 100644 index 0000000..6b0b476 --- /dev/null +++ b/.claude/skills/mantine-custom-components/SKILL.md @@ -0,0 +1,112 @@ +--- +name: mantine-custom-components +description: > + Build custom components that integrate with Mantine's theming, Styles API, and core features. + Use this skill when: (1) creating a new component using factory(), polymorphicFactory(), or + genericFactory(), (2) adding Styles API support (classNames, styles, vars, unstyled), (3) + implementing CSS variables via createVarsResolver, (4) building compound components with + sub-components and shared context, (5) registering a component with MantineProvider via + Component.extend(), or (6) any task involving Factory, useProps, useStyles, BoxProps, + StylesApiProps, or ElementProps in @mantine/core. +--- + +# Mantine Custom Components Skill + +## Component template + +```tsx +import { + Box, BoxProps, createVarsResolver, ElementProps, + factory, Factory, getRadius, MantineRadius, + StylesApiProps, useProps, useStyles, +} from '@mantine/core'; +import classes from './MyComponent.module.css'; + +export type MyComponentStylesNames = 'root' | 'inner'; +export type MyComponentVariant = 'filled' | 'outline'; +export type MyComponentCssVariables = { root: '--my-radius' }; + +export interface MyComponentProps + extends BoxProps, StylesApiProps, ElementProps<'div'> { + radius?: MantineRadius; +} + +export type MyComponentFactory = Factory<{ + props: MyComponentProps; + ref: HTMLDivElement; + stylesNames: MyComponentStylesNames; + vars: MyComponentCssVariables; + variant: MyComponentVariant; +}>; + +const defaultProps = { radius: 'md' } satisfies Partial; + +const varsResolver = createVarsResolver((_theme, { radius }) => ({ + root: { '--my-radius': getRadius(radius) }, +})); + +export const MyComponent = factory((_props) => { + const props = useProps('MyComponent', defaultProps, _props); + const { classNames, className, style, styles, unstyled, vars, attributes, radius, ...others } = props; + + const getStyles = useStyles({ + name: 'MyComponent', classes, props, + className, style, classNames, styles, unstyled, vars, attributes, varsResolver, + }); + + return ; +}); + +MyComponent.displayName = '@mantine/core/MyComponent'; +MyComponent.classes = classes; +``` + +## Factory variant — which to use + +| Scenario | Factory function | Type | +|---|---|---| +| Standard component | `factory()` | `Factory<{}>` | +| Supports `component` prop (polymorphic) | `polymorphicFactory()` | `PolymorphicFactory<{}>` — add `defaultComponent` and `defaultRef` | +| Props change based on a generic (e.g. `multiple`) | `genericFactory()` | `Factory<{ signature: ... }>` | + +Use `polymorphicFactory` sparingly — it adds TypeScript overhead and slows IDE autocomplete. + +## Factory type fields + +```ts +Factory<{ + props: MyComponentProps; // required + ref: HTMLDivElement; // element type for the forwarded ref + stylesNames: 'root' | 'inner'; // union of Styles API selectors + vars: { root: '--my-var' }; // CSS variable map per selector + variant: 'filled' | 'outline'; // accepted variant strings + staticComponents: { // sub-components (compound pattern) + Item: typeof MyComponentItem; + }; + compound?: boolean; // true = sub-component; disables theme classNames/styles/vars + ctx?: MyContextType; // passed to styles/vars resolvers as third arg + signature?: (...) => JSX.Element; // only for genericFactory +}> +``` + +## Theme integration + +Users and the theme can override defaults via `Component.extend()`: + +```ts +const theme = createTheme({ + components: { + MyComponent: MyComponent.extend({ + defaultProps: { radius: 'xl' }, + classNames: { root: 'my-root' }, + styles: { root: { color: 'red' } }, + vars: (_theme, props) => ({ root: { '--my-radius': getRadius(props.radius) } }), + }), + }, +}); +``` + +## References + +- **[`references/api.md`](references/api.md)** — All imports: `factory`, `useProps`, `useStyles`, `createVarsResolver`, `createSafeContext`, `StylesApiProps`, `CompoundStylesApiProps`, `BoxProps`, `ElementProps`, theme helpers (`getSize`, `getRadius`, etc.) +- **[`references/patterns.md`](references/patterns.md)** — Full examples: compound components with context, polymorphic component, generic component, theme integration diff --git a/.claude/skills/mantine-custom-components/references/api.md b/.claude/skills/mantine-custom-components/references/api.md new file mode 100644 index 0000000..911c74e --- /dev/null +++ b/.claude/skills/mantine-custom-components/references/api.md @@ -0,0 +1,407 @@ +# Custom Components API Reference + +## Table of Contents +- [Imports cheatsheet](#imports-cheatsheet) +- [factory / polymorphicFactory / genericFactory](#factory--polymorphicfactory--genericfactory) +- [Factory type fields](#factory-type-fields) +- [useProps](#usepropss) +- [useStyles](#usestyles) +- [createVarsResolver](#createvarsresolver) +- [StylesApiProps and CompoundStylesApiProps](#stylesapiprops-and-compoundstylesapiprops) +- [BoxProps and ElementProps](#boxprops-and-elementprops) +- [createSafeContext](#createsafecontext) +- [Theme helper functions](#theme-helper-functions) +- [Static properties](#static-properties) + +--- + +## Imports cheatsheet + +```ts +import { + // Factory functions + factory, + polymorphicFactory, + genericFactory, + + // Types + Factory, + PolymorphicFactory, + StylesApiProps, + CompoundStylesApiProps, + BoxProps, + ElementProps, + + // Hooks + useProps, + useStyles, + + // Vars + createVarsResolver, + + // Context + createSafeContext, + + // Base component + Box, + + // Theme helpers + getSize, + getSpacing, + getRadius, + getFontSize, + getLineHeight, + getShadow, + rem, + em, +} from '@mantine/core'; +``` + +--- + +## factory / polymorphicFactory / genericFactory + +### factory() + +Standard factory for non-polymorphic components. + +```ts +factory( + ui: (props: Payload['props'] & { ref?: React.Ref }) => React.ReactNode +): MantineComponent +``` + +The returned component has static properties: `.extend()`, `.withProps()`, `.classes`, `.displayName`, `.varsResolver` (if vars are used), and any sub-components assigned. + +### polymorphicFactory() + +For components that accept a `component` prop to render as a different element. Same signature as `factory()`, but uses `PolymorphicFactory` type. + +```ts +// Type uses PolymorphicFactory<{}> instead of Factory<{}> +export type MyFactory = PolymorphicFactory<{ + props: MyProps; + defaultRef: HTMLButtonElement; // default ref type + defaultComponent: 'button'; // default element + stylesNames: ...; + vars: ...; +}>; + +export const My = polymorphicFactory((_props) => { ... }); +``` + +### genericFactory() + +For components whose prop types depend on a generic argument. + +```ts +// Factory uses 'signature' field +export type MyFactory = Factory<{ + props: MyProps; + signature: (props: MyProps) => React.JSX.Element; + ref: HTMLDivElement; + // ... other fields +}>; + +export const My = genericFactory((_props) => { ... }); +``` + +--- + +## Factory type fields + +All fields except `props` are optional. + +```ts +Factory<{ + props: MyComponentProps; + + // Forwarded ref element type + ref: HTMLDivElement; + + // Union of Styles API selector strings (must match CSS module class names) + stylesNames: 'root' | 'label' | 'icon'; + + // CSS variables definition: { selectorName: '--var-name' | '--other-var' } + vars: { + root: '--my-height' | '--my-color'; + label: '--my-label-fz'; + }; + + // Accepted values for the variant prop + variant: 'filled' | 'outline' | 'subtle'; + + // Sub-components for compound pattern + staticComponents: { + Item: typeof MyItem; + Label: typeof MyLabel; + }; + + // Set to true for sub-components — disables theme classNames/styles/vars for this component + compound: true; + + // Context type passed as 3rd argument to styles/vars resolvers + ctx: { opened: boolean }; + + // Generic signature (genericFactory only) + signature: (props: MyProps) => React.JSX.Element; +}> +``` + +--- + +## useProps + +Merges default props from three sources in priority order (highest → lowest): +1. Props passed by the user +2. Default props from `MantineProvider` theme (`components.MyComponent.defaultProps`) +3. Component-level `defaultProps` + +```ts +useProps>( + componentName: string, // must match the name used in theme.components + defaultProps: Partial, // use 'satisfies Partial' for correct inference + props: T +): T +``` + +**Important:** Always call `useProps` before destructuring. Always use `satisfies Partial` (not `: Partial`) for `defaultProps` to preserve narrowed types. + +```ts +const defaultProps = { size: 'md', variant: 'filled' } satisfies Partial; + +const props = useProps('MyComponent', defaultProps, _props); +const { className, style, classNames, styles, unstyled, vars, attributes, ...others } = props; +``` + +--- + +## useStyles + +Returns a `getStyles` function that provides `className` and `style` for each Styles API selector. + +```ts +useStyles(input: { + name: string | string[]; // component name(s) for static CSS class generation + classes: Record; // CSS module classes object + props: Payload['props']; + stylesCtx?: Payload['ctx']; // optional context for styles/vars resolvers + className?: string; // spread to rootSelector + style?: MantineStyleProp; // spread to rootSelector + rootSelector?: string; // which selector gets className/style (default: 'root') + unstyled?: boolean; + classNames?: ClassNames; + styles?: Styles; + vars?: PartialVarsResolver; + varsResolver?: VarsResolver; + attributes?: Attributes; +}): GetStylesApi +``` + +**`getStyles` function:** +```ts +getStyles( + selector: StylesNames, + options?: { + className?: string; // additional className merged in + style?: CSSProperties; // additional style merged in + focusable?: boolean; + active?: boolean; + withStaticClass?: boolean; + } +): { className: string; style: CSSProperties } +``` + +Usage: +```tsx + +
+``` + +--- + +## createVarsResolver + +Defines how component props map to CSS variables. + +```ts +createVarsResolver( + resolver: ( + theme: MantineTheme, + props: Payload['props'], + ctx: Payload['ctx'] // only if Factory has ctx field + ) => TransformVars +): VarsResolver +``` + +The resolver must return an object matching the `vars` structure defined in `Factory`: + +```ts +// Factory vars: { root: '--my-height' | '--my-color' } +const varsResolver = createVarsResolver((_theme, { size, color }) => ({ + root: { + '--my-height': getSize(size, 'my-height'), + '--my-color': color ?? undefined, // undefined = CSS var not set (uses CSS fallback) + }, +})); +``` + +Assign to the component after creation: +```ts +MyComponent.varsResolver = varsResolver; +``` + +--- + +## StylesApiProps and CompoundStylesApiProps + +**`StylesApiProps`** — extend on the root/main component's props interface: +```ts +interface StylesApiProps { + unstyled?: boolean; + variant?: Payload['variant'] | (string & {}); + classNames?: ClassNames; // { root: 'my-class', inner: 'other' } or callback + styles?: Styles; // { root: { color: 'red' } } or callback + vars?: PartialVarsResolver; // (theme, props) => { root: { '--my-var': '...' } } + attributes?: Attributes; // { root: { 'data-custom': value } } +} +``` + +**`CompoundStylesApiProps`** — extend on sub-component (compound) props instead. Subset of `StylesApiProps` — no `unstyled` or `attributes`. + +```ts +interface CompoundStylesApiProps + extends Omit, 'unstyled' | 'attributes'> {} +``` + +Compound sub-components also use `Factory<{ ..., compound: true }>` and access styles via the parent context's `getStyles`. + +--- + +## BoxProps and ElementProps + +**`BoxProps`** — extends `MantineStyleProps`, adds: +```ts +interface BoxProps extends MantineStyleProps { + className?: string; + style?: MantineStyleProp; // accepts function: (theme) => CSSProperties + mod?: string | Record | (string | Record)[]; // data-* attributes + hiddenFrom?: MantineBreakpoint; // hidden at this breakpoint and above + visibleFrom?: MantineBreakpoint; // visible only at this breakpoint and above + lightHidden?: boolean; // hidden in light color scheme + darkHidden?: boolean; // hidden in dark color scheme +} +``` + +**`MantineStyleProps`** — shorthand style props (all accept responsive `{ base, sm, md, lg, xl }` objects): + +| Prop | CSS property | Prop | CSS property | +|---|---|---|---| +| `m` `mt` `mb` `ml` `mr` `mx` `my` `ms` `me` | margin variants | `p` `pt` `pb` `pl` `pr` `px` `py` `ps` `pe` | padding variants | +| `w` `miw` `maw` | width | `h` `mih` `mah` | height | +| `c` | color | `bg` | background | +| `fz` | font-size | `fw` | font-weight | +| `ff` | font-family | `fs` | font-style | +| `lh` | line-height | `lts` | letter-spacing | +| `ta` | text-align | `tt` | text-transform | +| `td` | text-decoration | `bd` | border | +| `bdrs` | border-radius | `opacity` | opacity | +| `pos` | position | `top` `left` `bottom` `right` `inset` | positioning | +| `display` | display | `flex` | flex | + +**`ElementProps`** — gets HTML element props, remapping `style` to Mantine's type: +```ts +// Include all div props except style (remapped) and any conflicting props +interface MyProps extends ElementProps<'div'> {} + +// Omit conflicting HTML attrs (e.g. input has native 'size' and 'color') +interface MyProps extends ElementProps<'input', 'size' | 'color'> { + size?: MantineSize; + color?: MantineColor; +} + +// Can also accept a React component type instead of element string +interface MyProps extends ElementProps {} +``` + +--- + +## createSafeContext + +Used inside compound components to share state from the parent to sub-components. + +```ts +createSafeContext( + errorMessage: string // thrown when hook is used outside the provider +): [ + Context: React.Context, + useContext: () => ContextValue // throws errorMessage if used outside provider +] +``` + +**Usage pattern (in ComponentName.context.ts):** +```ts +import { createSafeContext, GetStylesApi } from '@mantine/core'; +import { MyFactory } from './MyComponent'; + +interface MyContextValue { + getStyles: GetStylesApi; + // other shared state... +} + +export const [MyProvider, useMyContext] = createSafeContext( + 'MyComponent was not found in tree' +); +``` + +In the root component: +```tsx +return ( + + {children} + +); +``` + +In sub-components: +```tsx +const { getStyles } = useMyContext(); +return
; +``` + +--- + +## Theme helper functions + +Use these in `createVarsResolver` to convert Mantine size tokens to CSS values: + +| Function | Input | Output example | +|---|---|---| +| `getSize(size, prefix)` | `'sm'`, `'button-height'` | `'var(--mantine-button-height-sm)'` | +| `getSpacing(size)` | `'md'` or `16` | `'var(--mantine-spacing-md)'` or `'1rem'` | +| `getRadius(size)` | `'sm'` or `4` | `'var(--mantine-radius-sm)'` or `'0.25rem'` | +| `getFontSize(size)` | `'sm'` | `'var(--mantine-font-size-sm)'` | +| `getLineHeight(size)` | `'sm'` | `'var(--mantine-line-height-sm)'` | +| `getShadow(size)` | `'md'` | `'var(--mantine-shadow-md)'` | +| `rem(value)` | `16` | `'1rem'` | +| `em(value)` | `16` | `'1em'` | + +Return `undefined` from a var resolver entry to leave that CSS variable unset (CSS fallback applies). + +--- + +## Static properties + +These must be set on every component after creation: + +```ts +MyComponent.displayName = '@mantine/core/MyComponent'; // or '@mantine/package/Name' +MyComponent.classes = classes; // CSS module classes object +MyComponent.varsResolver = varsResolver; // only if component defines vars + +// Sub-components (compound pattern) +MyComponent.Item = MyItem; +MyComponent.Label = MyLabel; +``` + +`.extend()` and `.withProps()` are added automatically by `factory()`. diff --git a/.claude/skills/mantine-custom-components/references/patterns.md b/.claude/skills/mantine-custom-components/references/patterns.md new file mode 100644 index 0000000..d243e99 --- /dev/null +++ b/.claude/skills/mantine-custom-components/references/patterns.md @@ -0,0 +1,431 @@ +# Custom Component Patterns + +## Table of Contents +- [Minimal component (no styles API)](#minimal-component-no-styles-api) +- [Component with CSS variables](#component-with-css-variables) +- [Compound component with context](#compound-component-with-context) +- [Polymorphic component](#polymorphic-component) +- [Generic component](#generic-component) +- [Theme integration](#theme-integration) +- [Namespace exports](#namespace-exports) + +--- + +## Minimal component (no styles API) + +When you don't need theming/Styles API support — just Box + useProps. + +```tsx +import { Box, BoxProps, ElementProps, factory, Factory, useProps } from '@mantine/core'; + +export interface MinimalProps extends BoxProps, ElementProps<'div'> { + label?: string; +} + +export type MinimalFactory = Factory<{ + props: MinimalProps; + ref: HTMLDivElement; +}>; + +const defaultProps = {} satisfies Partial; + +export const Minimal = factory((_props) => { + const props = useProps('Minimal', defaultProps, _props); + const { label, children, ...others } = props; + + return ( + + {label && {label}} + {children} + + ); +}); + +Minimal.displayName = '@mantine/core/Minimal'; +``` + +--- + +## Component with CSS variables + +Full example with Styles API, CSS variables, and theme integration. + +**MyComponent.module.css:** +```css +.root { + border-radius: var(--my-radius); + padding: var(--my-padding); +} + +.inner { + font-size: var(--my-fz); +} +``` + +**MyComponent.tsx:** +```tsx +import { + Box, BoxProps, createVarsResolver, ElementProps, factory, Factory, + getFontSize, getRadius, getSpacing, MantineFontSize, MantineRadius, + MantineSpacing, StylesApiProps, useProps, useStyles, +} from '@mantine/core'; +import classes from './MyComponent.module.css'; + +export type MyComponentStylesNames = 'root' | 'inner'; +export type MyComponentVariant = 'filled' | 'outline'; +export type MyComponentCssVariables = { + root: '--my-radius' | '--my-padding'; + inner: '--my-fz'; +}; + +export interface MyComponentProps + extends BoxProps, StylesApiProps, ElementProps<'div'> { + radius?: MantineRadius; + padding?: MantineSpacing; + size?: MantineFontSize; + variant?: MyComponentVariant; +} + +export type MyComponentFactory = Factory<{ + props: MyComponentProps; + ref: HTMLDivElement; + stylesNames: MyComponentStylesNames; + vars: MyComponentCssVariables; + variant: MyComponentVariant; +}>; + +const defaultProps = { + radius: 'sm', + padding: 'md', + size: 'md', +} satisfies Partial; + +const varsResolver = createVarsResolver((_theme, { radius, padding, size }) => ({ + root: { + '--my-radius': getRadius(radius), + '--my-padding': getSpacing(padding), + }, + inner: { + '--my-fz': getFontSize(size), + }, +})); + +export const MyComponent = factory((_props) => { + const props = useProps('MyComponent', defaultProps, _props); + const { + classNames, className, style, styles, unstyled, vars, attributes, + radius, padding, size, + children, + ...others + } = props; + + const getStyles = useStyles({ + name: 'MyComponent', + classes, + props, + className, + style, + classNames, + styles, + unstyled, + vars, + attributes, + varsResolver, + }); + + return ( + +
{children}
+
+ ); +}); + +MyComponent.displayName = '@mantine/core/MyComponent'; +MyComponent.classes = classes; +MyComponent.varsResolver = varsResolver; +``` + +--- + +## Compound component with context + +Pattern for components with typed sub-components (e.g. `Card.Section`, `Tabs.Tab`). + +**MyCard.context.ts:** +```ts +import { createSafeContext, GetStylesApi } from '@mantine/core'; +import type { MyCardFactory } from './MyCard'; + +interface MyCardContextValue { + getStyles: GetStylesApi; + orientation: 'horizontal' | 'vertical'; +} + +export const [MyCardProvider, useMyCardContext] = createSafeContext( + 'MyCard component was not found in tree' +); +``` + +**MyCardSection.tsx** (sub-component): +```tsx +import { + Box, BoxProps, CompoundStylesApiProps, ElementProps, + factory, Factory, useProps, useStyles, +} from '@mantine/core'; +import { useMyCardContext } from './MyCard.context'; +import classes from './MyCard.module.css'; + +export type MyCardSectionStylesNames = 'section'; + +export interface MyCardSectionProps + extends BoxProps, CompoundStylesApiProps, ElementProps<'div'> { + withBorder?: boolean; +} + +export type MyCardSectionFactory = Factory<{ + props: MyCardSectionProps; + ref: HTMLDivElement; + stylesNames: MyCardSectionStylesNames; + compound: true; // marks as a compound sub-component +}>; + +const defaultProps = {} satisfies Partial; + +export const MyCardSection = factory((_props) => { + const props = useProps('MyCardSection', defaultProps, _props); + const { className, style, classNames, styles, withBorder, children, ...others } = props; + + // Access styles from parent context + const { getStyles } = useMyCardContext(); + + return ( + + {children} + + ); +}); + +MyCardSection.displayName = '@mantine/core/MyCardSection'; +``` + +**MyCard.tsx** (root component): +```tsx +import { MyCardProvider } from './MyCard.context'; + +// ... (same Styles API setup as above) + +export type MyCardFactory = Factory<{ + props: MyCardProps; + ref: HTMLDivElement; + stylesNames: 'root' | 'section'; // include sub-component selectors too + staticComponents: { + Section: typeof MyCardSection; + }; +}>; + +export const MyCard = factory((_props) => { + const props = useProps('MyCard', defaultProps, _props); + const { + classNames, className, style, styles, unstyled, vars, attributes, + orientation, children, ...others + } = props; + + const getStyles = useStyles({ ... }); + + return ( + + {children} + + ); +}); + +MyCard.displayName = '@mantine/core/MyCard'; +MyCard.classes = classes; +MyCard.Section = MyCardSection; // attach sub-component +``` + +--- + +## Polymorphic component + +Supports `component` prop to render as any element or React component. + +```tsx +import { + Box, BoxProps, polymorphicFactory, PolymorphicFactory, + StylesApiProps, useProps, useStyles, +} from '@mantine/core'; + +export type MyLinkStylesNames = 'root'; + +export interface MyLinkProps extends BoxProps, StylesApiProps { + active?: boolean; +} + +export type MyLinkFactory = PolymorphicFactory<{ + props: MyLinkProps; + defaultRef: HTMLAnchorElement; + defaultComponent: 'a'; // renders as
unless component prop is provided + stylesNames: MyLinkStylesNames; +}>; + +const defaultProps = {} satisfies Partial; + +export const MyLink = polymorphicFactory((_props) => { + const props = useProps('MyLink', defaultProps, _props); + const { + classNames, className, style, styles, unstyled, vars, attributes, + active, ...others + } = props; + + const getStyles = useStyles({ + name: 'MyLink', classes, props, className, style, + classNames, styles, unstyled, vars, attributes, + }); + + return ( + + ); +}); + +MyLink.displayName = '@mantine/core/MyLink'; +MyLink.classes = classes; +``` + +**Usage:** +```tsx +Link +As button +Router link +``` + +--- + +## Generic component + +For components where prop types depend on a generic parameter. + +```tsx +import { factory, Factory, genericFactory, useProps } from '@mantine/core'; + +type SelectValue = M extends true ? string[] : string | null; + +export interface MySelectProps + extends BoxProps, StylesApiProps { + multiple?: M; + value?: SelectValue; + defaultValue?: SelectValue; + onChange?: (value: SelectValue) => void; +} + +export type MySelectFactory = Factory<{ + props: MySelectProps; + ref: HTMLDivElement; + signature: (props: MySelectProps) => React.JSX.Element; + stylesNames: 'root'; +}>; + +const defaultProps = { multiple: false } satisfies Partial; + +export const MySelect = genericFactory((_props) => { + const props = useProps('MySelect', defaultProps as any, _props); + const { multiple, value, onChange, ...others } = props; + // ... +}); + +MySelect.displayName = '@mantine/core/MySelect'; +``` + +**Usage:** +```tsx +// TypeScript infers value as string | null + setVal(v)} /> + +// TypeScript infers value as string[] + setVals(v)} /> +``` + +--- + +## Theme integration + +Components built with `factory()` automatically get `.extend()` and `.withProps()`. + +**`.extend()`** — for theme-level configuration in `createTheme`: +```tsx +const theme = createTheme({ + components: { + MyComponent: MyComponent.extend({ + // Override default props + defaultProps: { + radius: 'xl', + size: 'lg', + }, + // Add classes to selectors + classNames: { + root: 'my-root-class', + inner: 'my-inner-class', + }, + // Add inline styles to selectors + styles: { + root: { border: '1px solid red' }, + }, + // Or use a callback for theme-aware styles + styles: (theme) => ({ + root: { background: theme.colors.blue[0] }, + }), + // Override CSS variables + vars: (_theme, props) => ({ + root: { '--my-radius': props.radius ? getRadius(props.radius) : undefined }, + }), + }), + }, +}); +``` + +**`.withProps()`** — create a pre-configured variant at the call site: +```tsx +const BigMyComponent = MyComponent.withProps({ size: 'xl', radius: 'lg' }); + +// Same as MyComponent but with size and radius pre-set +Content +``` + +--- + +## Namespace exports + +Add at the bottom of the component file or `index.ts` to let consumers access types without extra imports. + +```tsx +export namespace MyComponent { + export type Props = MyComponentProps; + export type StylesNames = MyComponentStylesNames; + export type CssVariables = MyComponentCssVariables; + export type Factory = MyComponentFactory; + export type Variant = MyComponentVariant; + + export namespace Section { + export type Props = MyComponentSectionProps; + export type StylesNames = MyComponentSectionStylesNames; + export type Factory = MyComponentSectionFactory; + } +} +``` + +**Usage:** +```ts +import { MyComponent } from './MyComponent'; + +// No need to import MyComponentProps separately +const props: MyComponent.Props = { radius: 'md' }; +``` diff --git a/.claude/skills/tanstack-form/SKILL.md b/.claude/skills/tanstack-form/SKILL.md new file mode 100644 index 0000000..944941a --- /dev/null +++ b/.claude/skills/tanstack-form/SKILL.md @@ -0,0 +1,416 @@ +--- +name: tanstack-form +description: Headless, performant, and type-safe form state management for TS/JS, React, Vue, Angular, Solid, Lit, and Svelte. +--- + + +## Overview + +TanStack Form is a headless form library with deep TypeScript integration. It provides field-level and form-level validation (sync/async), array fields, linked/dependent fields, fine-grained reactivity, and schema validation adapter support (Zod, Valibot, Yup). + +**Package:** `@tanstack/react-form` +**Adapters:** `@tanstack/zod-form-adapter`, `@tanstack/valibot-form-adapter` +**Status:** Stable (v1) + +## Installation + +```bash +npm install @tanstack/react-form +# Optional schema adapters: +npm install @tanstack/zod-form-adapter zod +npm install @tanstack/valibot-form-adapter valibot +``` + +## Core: useForm + +```tsx +import { useForm } from '@tanstack/react-form' + +function MyForm() { + const form = useForm({ + defaultValues: { + firstName: '', + lastName: '', + email: '', + age: 0, + }, + onSubmit: async ({ value }) => { + // value is fully typed + await submitToServer(value) + }, + onSubmitInvalid: ({ value, formApi }) => { + console.log('Validation failed:', formApi.state.errors) + }, + }) + + return ( +
{ + e.preventDefault() + e.stopPropagation() + form.handleSubmit() + }} + > + {/* Fields */} + ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })} + children={({ canSubmit, isSubmitting }) => ( + + )} + /> + + ) +} +``` + +## Fields (form.Field) + +```tsx + + value.length < 3 ? 'Must be at least 3 characters' : undefined, + }} + children={(field) => ( +
+ + field.handleChange(e.target.value)} + /> + {field.state.meta.isTouched && field.state.meta.errors.length > 0 && ( + {field.state.meta.errors.join(', ')} + )} +
+ )} +/> + + + + {(field) => ( + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + /> + )} + +``` + +## Validation + +### Validation Timing + +| Cause | When | +|-------|------| +| `onChange` | After every value change | +| `onBlur` | When field loses focus | +| `onSubmit` | During submission | +| `onMount` | When field mounts | + +### Synchronous Validation + +```tsx + { + if (value < 18) return 'Must be 18 or older' + return undefined // undefined = valid + }, + onBlur: ({ value }) => { + if (!value) return 'Required' + return undefined + }, + }} +/> +``` + +### Asynchronous Validation + +```tsx + { + const res = await fetch(`/api/check-username?q=${value}`) + const { available } = await res.json() + if (!available) return 'Username taken' + return undefined + }, + }} +> + {(field) => ( + <> + field.handleChange(e.target.value)} /> + {field.state.meta.isValidating && Checking...} + + )} + +``` + +### Schema Validation (Zod) + +```tsx +import { zodValidator } from '@tanstack/zod-form-adapter' +import { z } from 'zod' + +const form = useForm({ + defaultValues: { email: '', age: 0 }, + validatorAdapter: zodValidator(), + onSubmit: async ({ value }) => { /* ... */ }, +}) + + + + +``` + +### Form-Level Validation + +```tsx +const form = useForm({ + defaultValues: { password: '', confirmPassword: '' }, + validators: { + onChange: ({ value }) => { + if (value.password !== value.confirmPassword) { + return 'Passwords do not match' + } + return undefined + }, + }, +}) +``` + +### Linked/Dependent Fields + +```tsx + { + const password = fieldApi.form.getFieldValue('password') + if (value !== password) return 'Passwords do not match' + return undefined + }, + }} +/> +``` + +## Array Fields + +```tsx + + {(field) => ( +
+ {field.state.value.map((_, index) => ( +
+ + {(subField) => ( + subField.handleChange(e.target.value)} + /> + )} + + +
+ ))} + +
+ )} +
+``` + +### Array Methods + +```typescript +field.pushValue(item) // Add to end +field.insertValue(index, item) // Insert at index +field.replaceValue(index, item) // Replace at index +field.removeValue(index) // Remove at index +field.swapValues(indexA, indexB) // Swap positions +field.moveValue(from, to) // Move position +``` + +## Listeners (Side Effects) + +```tsx + { + // Side effect: reset dependent fields + form.setFieldValue('state', '') + form.setFieldValue('postalCode', '') + }, + }} +/> +``` + +## Reactivity (form.Subscribe & useStore) + +```tsx +// Render-prop subscription (fine-grained) + ({ canSubmit: state.canSubmit, isDirty: state.isDirty })} + children={({ canSubmit, isDirty }) => ( +
+ {isDirty && Unsaved changes} + +
+ )} +/> + +// Hook-based subscription +function FormStatus() { + const isValid = form.useStore((s) => s.isValid) + return isValid ? null :

Fix errors

+} +``` + +## Form State + +```typescript +interface FormState { + values: TFormData + errors: ValidationError[] + errorMap: Record + isFormValid: boolean + isFieldsValid: boolean + isValid: boolean // isFormValid && isFieldsValid + isTouched: boolean + isPristine: boolean + isDirty: boolean + isSubmitting: boolean + isSubmitted: boolean + isSubmitSuccessful: boolean + submissionAttempts: number + canSubmit: boolean // isValid && !isSubmitting +} +``` + +## Field State + +```typescript +interface FieldState { + value: TData + meta: { + isTouched: boolean + isDirty: boolean + isPristine: boolean + isValidating: boolean + errors: ValidationError[] + errorMap: Record + } +} +``` + +## FormApi Methods + +```typescript +form.handleSubmit() +form.reset() +form.getFieldValue(field) +form.setFieldValue(field, value) +form.getFieldMeta(field) +form.setFieldMeta(field, updater) +form.validateAllFields(cause) +form.validateField(field, cause) +form.deleteField(field) +``` + +## Shared Form Options (formOptions) + +```tsx +import { formOptions } from '@tanstack/react-form' + +const sharedOpts = formOptions({ + defaultValues: { firstName: '', lastName: '' }, +}) + +// Reuse across components +const form = useForm({ + ...sharedOpts, + onSubmit: async ({ value }) => { /* ... */ }, +}) +``` + +## Server-Side Validation + +```tsx +// TanStack Start / Next.js server action +import { ServerValidateError } from '@tanstack/react-form/nextjs' + +export async function validateForm(data: FormData) { + const email = data.get('email') as string + if (await checkEmailExists(email)) { + throw new ServerValidateError({ + form: 'Submission failed', + fields: { email: 'Email already registered' }, + }) + } +} +``` + +## TypeScript Integration + +```tsx +// Type-safe field paths with DeepKeys +interface UserForm { + name: string + address: { street: string; city: string } + tags: string[] + contacts: Array<{ name: string; phone: string }> +} + +// TypeScript auto-completes all valid paths: +// 'name', 'address', 'address.street', 'address.city', 'tags', 'contacts' + // OK + // Type Error! +``` + +## Best Practices + +1. **Always call `e.preventDefault()` and `e.stopPropagation()`** on form submit +2. **Always attach `onBlur={field.handleBlur}`** for blur validation and isTouched tracking +3. **Use `mode="array"`** for array fields to get array methods +4. **Return `undefined`** (not null/false) for valid validators +5. **Use `asyncDebounceMs`** for async validators to prevent API spam +6. **Check `isTouched` before showing errors** for better UX +7. **Use `form.Subscribe` with selectors** to minimize re-renders +8. **Use `formOptions`** for shared configuration across components +9. **Use schema validators** (Zod/Valibot) for complex validation rules +10. **Use `onChangeListenTo`** for cross-field validation dependencies + +## Common Pitfalls + +- Forgetting `e.preventDefault()` on form submit (causes page reload) +- Not attaching `onBlur` to inputs (breaks blur validation and isTouched) +- Returning `null` or `false` instead of `undefined` for valid fields +- Using `mode="array"` incorrectly (only needed on the array field itself, not sub-fields) +- Subscribing to entire form state instead of using selectors (unnecessary re-renders) +- Not using `asyncDebounceMs` with async validators (fires on every keystroke) diff --git a/.claude/skills/tanstack-query/SKILL.md b/.claude/skills/tanstack-query/SKILL.md new file mode 100644 index 0000000..1538eb4 --- /dev/null +++ b/.claude/skills/tanstack-query/SKILL.md @@ -0,0 +1,849 @@ +--- +name: tanstack-query +description: Powerful asynchronous state management, server-state utilities, and data fetching for TS/JS, React, Vue, Solid, Svelte & Angular. +--- + + +## Overview + +TanStack Query (formerly React Query) manages server state - data that lives on the server and needs to be fetched, cached, synchronized, and updated. It provides automatic caching, background refetching, stale-while-revalidate patterns, pagination, infinite scrolling, and optimistic updates out of the box. + +**Package:** `@tanstack/react-query` +**Devtools:** `@tanstack/react-query-devtools` +**Current Version:** v5 + +## Installation + +```bash +npm install @tanstack/react-query +npm install -D @tanstack/react-query-devtools # Optional +``` + +## Setup + +```tsx +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60, // 1 minute + gcTime: 1000 * 60 * 5, // 5 minutes (garbage collection) + retry: 3, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + }, + }, +}) + +function App() { + return ( + + + + + ) +} +``` + +## Core Concepts + +### Query Keys + +Query keys uniquely identify cached data. They must be serializable arrays: + +```tsx +// Simple key +useQuery({ queryKey: ['todos'], queryFn: fetchTodos }) + +// With variables (dependency array pattern) +useQuery({ queryKey: ['todos', { status, page }], queryFn: fetchTodos }) + +// Hierarchical keys for invalidation +useQuery({ queryKey: ['todos', todoId], queryFn: () => fetchTodo(todoId) }) +useQuery({ queryKey: ['todos', todoId, 'comments'], queryFn: () => fetchComments(todoId) }) + +// Invalidation matches prefixes: +// queryClient.invalidateQueries({ queryKey: ['todos'] }) +// ^ Invalidates ALL queries starting with 'todos' +``` + +### Query Functions + +```tsx +// Query function receives a QueryFunctionContext +useQuery({ + queryKey: ['todos', todoId], + queryFn: async ({ queryKey, signal, meta }) => { + const [_key, id] = queryKey + const response = await fetch(`/api/todos/${id}`, { signal }) + if (!response.ok) throw new Error('Failed to fetch') + return response.json() + }, +}) + +// Using the signal for automatic cancellation +useQuery({ + queryKey: ['todos'], + queryFn: async ({ signal }) => { + const response = await fetch('/api/todos', { signal }) + return response.json() + }, +}) +``` + +### queryOptions Helper + +Create reusable, type-safe query configurations: + +```tsx +import { queryOptions } from '@tanstack/react-query' + +export const todosQueryOptions = queryOptions({ + queryKey: ['todos'], + queryFn: fetchTodos, + staleTime: 5000, +}) + +export const todoQueryOptions = (todoId: string) => + queryOptions({ + queryKey: ['todos', todoId], + queryFn: () => fetchTodo(todoId), + enabled: !!todoId, + }) + +// Usage +const { data } = useQuery(todosQueryOptions) +const { data } = useSuspenseQuery(todoQueryOptions(id)) +await queryClient.prefetchQuery(todosQueryOptions) +``` + +## Queries (useQuery) + +### Basic Usage + +```tsx +import { useQuery } from '@tanstack/react-query' + +function Todos() { + const { + data, + error, + isLoading, // First load, no data yet + isFetching, // Any fetch in progress (including background) + isError, + isSuccess, + isPending, // No data yet (same as isLoading in most cases) + status, // 'pending' | 'error' | 'success' + fetchStatus, // 'fetching' | 'paused' | 'idle' + refetch, + isStale, + isPlaceholderData, + dataUpdatedAt, + errorUpdatedAt, + } = useQuery({ + queryKey: ['todos'], + queryFn: fetchTodos, + }) + + if (isLoading) return + if (isError) return + return +} +``` + +### Query Options + +```tsx +useQuery({ + queryKey: ['todos'], + queryFn: fetchTodos, + + // Freshness + staleTime: 5000, // ms data stays fresh (default: 0) + gcTime: 300000, // ms unused data stays in cache (default: 5 min) + + // Refetching + refetchInterval: 10000, // Poll every 10s + refetchIntervalInBackground: false, // Don't poll when tab hidden + refetchOnMount: true, // Refetch on component mount if stale + refetchOnWindowFocus: true, // Refetch on window focus if stale + refetchOnReconnect: true, // Refetch on network reconnect + + // Retry + retry: 3, // Number of retries (or function) + retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), + + // Conditional + enabled: !!userId, // Only run when truthy + + // Initial/placeholder data + initialData: () => cachedData, + initialDataUpdatedAt: Date.now() - 10000, + placeholderData: (previousData) => previousData, // keepPreviousData pattern + placeholderData: initialTodos, + + // Transform + select: (data) => data.filter(todo => !todo.done), + + // Structural sharing (default: true) + structuralSharing: true, + + // Network mode + networkMode: 'online', // 'online' | 'always' | 'offlineFirst' + + // Meta (accessible in query function context) + meta: { purpose: 'user-facing' }, +}) +``` + +## Mutations (useMutation) + +### Basic Usage + +```tsx +import { useMutation, useQueryClient } from '@tanstack/react-query' + +function AddTodo() { + const queryClient = useQueryClient() + + const mutation = useMutation({ + mutationFn: (newTodo: { title: string }) => { + return fetch('/api/todos', { + method: 'POST', + body: JSON.stringify(newTodo), + }).then(res => res.json()) + }, + // Lifecycle callbacks + onMutate: async (variables) => { + // Called before mutationFn + // Good for optimistic updates + return { previousTodos } // context for onError + }, + onSuccess: (data, variables, context) => { + // Invalidate related queries + queryClient.invalidateQueries({ queryKey: ['todos'] }) + }, + onError: (error, variables, context) => { + // Rollback optimistic updates + queryClient.setQueryData(['todos'], context.previousTodos) + }, + onSettled: (data, error, variables, context) => { + // Always runs (success or error) + queryClient.invalidateQueries({ queryKey: ['todos'] }) + }, + }) + + return ( + + ) +} +``` + +### Mutation State + +```tsx +const { + mutate, // Fire-and-forget + mutateAsync, // Returns promise + isPending, // Mutation in progress + isError, + isSuccess, + isIdle, // Not yet fired + data, // Success response + error, // Error object + reset, // Reset state to idle + variables, // Variables passed to mutate + status, // 'idle' | 'pending' | 'error' | 'success' +} = useMutation({ ... }) +``` + +## Optimistic Updates + +```tsx +const mutation = useMutation({ + mutationFn: updateTodo, + onMutate: async (newTodo) => { + // 1. Cancel outgoing refetches + await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] }) + + // 2. Snapshot previous value + const previousTodo = queryClient.getQueryData(['todos', newTodo.id]) + + // 3. Optimistically update + queryClient.setQueryData(['todos', newTodo.id], newTodo) + + // 4. Return context for rollback + return { previousTodo } + }, + onError: (err, newTodo, context) => { + // Rollback on error + queryClient.setQueryData(['todos', newTodo.id], context.previousTodo) + }, + onSettled: () => { + // Always refetch to sync with server + queryClient.invalidateQueries({ queryKey: ['todos'] }) + }, +}) +``` + +### Optimistic Updates on Lists + +```tsx +onMutate: async (newTodo) => { + await queryClient.cancelQueries({ queryKey: ['todos'] }) + const previousTodos = queryClient.getQueryData(['todos']) + + queryClient.setQueryData(['todos'], (old) => [...old, newTodo]) + + return { previousTodos } +}, +onError: (err, newTodo, context) => { + queryClient.setQueryData(['todos'], context.previousTodos) +}, +``` + +## Query Invalidation + +```tsx +const queryClient = useQueryClient() + +// Invalidate all queries +queryClient.invalidateQueries() + +// Invalidate by prefix +queryClient.invalidateQueries({ queryKey: ['todos'] }) + +// Invalidate exact match +queryClient.invalidateQueries({ queryKey: ['todos', 1], exact: true }) + +// Invalidate with predicate +queryClient.invalidateQueries({ + predicate: (query) => + query.queryKey[0] === 'todos' && query.queryKey[1]?.status === 'done', +}) + +// Invalidate and refetch immediately +queryClient.refetchQueries({ queryKey: ['todos'] }) + +// Remove from cache entirely +queryClient.removeQueries({ queryKey: ['todos', 1] }) + +// Reset to initial state +queryClient.resetQueries({ queryKey: ['todos'] }) +``` + +## Infinite Queries + +```tsx +import { useInfiniteQuery } from '@tanstack/react-query' + +function InfiniteList() { + const { + data, + fetchNextPage, + fetchPreviousPage, + hasNextPage, + hasPreviousPage, + isFetchingNextPage, + isFetchingPreviousPage, + } = useInfiniteQuery({ + queryKey: ['projects'], + queryFn: async ({ pageParam }) => { + const res = await fetch(`/api/projects?cursor=${pageParam}`) + return res.json() + }, + initialPageParam: 0, + getNextPageParam: (lastPage, allPages, lastPageParam) => { + return lastPage.nextCursor ?? undefined // undefined = no more pages + }, + getPreviousPageParam: (firstPage, allPages, firstPageParam) => { + return firstPage.prevCursor ?? undefined + }, + maxPages: 3, // Keep max 3 pages in cache (for performance) + }) + + return ( +
+ {data.pages.map((page) => + page.items.map((item) => ) + )} + +
+ ) +} +``` + +## Parallel Queries + +```tsx +// Multiple independent queries run in parallel automatically +function Dashboard() { + const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers }) + const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: fetchProjects }) + + // Both fetch simultaneously +} + +// Dynamic parallel queries with useQueries +function UserProjects({ userIds }) { + const queries = useQueries({ + queries: userIds.map((id) => ({ + queryKey: ['user', id], + queryFn: () => fetchUser(id), + })), + combine: (results) => ({ + data: results.map(r => r.data), + pending: results.some(r => r.isPending), + }), + }) +} +``` + +## Dependent Queries + +```tsx +// Sequential queries using enabled +function UserPosts({ userId }) { + const userQuery = useQuery({ + queryKey: ['user', userId], + queryFn: () => fetchUser(userId), + }) + + const postsQuery = useQuery({ + queryKey: ['posts', userId], + queryFn: () => fetchPostsByUser(userId), + enabled: !!userQuery.data, // Only run when user is loaded + }) +} +``` + +## Paginated Queries + +```tsx +function PaginatedList() { + const [page, setPage] = useState(1) + + const { data, isPlaceholderData } = useQuery({ + queryKey: ['todos', page], + queryFn: () => fetchTodos(page), + placeholderData: (previousData) => previousData, // Keep showing old data + }) + + return ( +
+ {data.items.map(item => )} + +
+ ) +} +``` + +## Suspense Integration + +```tsx +import { useSuspenseQuery, useSuspenseInfiniteQuery } from '@tanstack/react-query' + +// Component will suspend until data is loaded +function TodoList() { + const { data } = useSuspenseQuery({ + queryKey: ['todos'], + queryFn: fetchTodos, + }) + // data is guaranteed to be defined here + return
    {data.map(todo =>
  • {todo.title}
  • )}
+} + +// Wrap with Suspense boundary +function App() { + return ( + }> + }> + + + + ) +} + +// Multiple suspense queries (fetch in parallel) +function Dashboard() { + const [{ data: users }, { data: projects }] = useSuspenseQueries({ + queries: [ + { queryKey: ['users'], queryFn: fetchUsers }, + { queryKey: ['projects'], queryFn: fetchProjects }, + ], + }) +} +``` + +## Prefetching + +```tsx +const queryClient = useQueryClient() + +// Prefetch on hover +function TodoLink({ todoId }) { + const prefetch = () => { + queryClient.prefetchQuery({ + queryKey: ['todo', todoId], + queryFn: () => fetchTodo(todoId), + staleTime: 5000, // Only prefetch if data older than 5s + }) + } + + return ( + + Todo {todoId} + + ) +} + +// Prefetch in route loader (TanStack Router integration) +export const Route = createFileRoute('/todos/$todoId')({ + loader: ({ context: { queryClient }, params: { todoId } }) => + queryClient.ensureQueryData(todoQueryOptions(todoId)), +}) + +// Prefetch infinite queries +queryClient.prefetchInfiniteQuery({ + queryKey: ['projects'], + queryFn: fetchProjects, + initialPageParam: 0, + pages: 3, // Prefetch first 3 pages +}) +``` + +## SSR & Hydration + +### Server-Side Prefetching + +```tsx +// Server component or loader +import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query' + +async function getServerSideProps() { + const queryClient = new QueryClient() + + await queryClient.prefetchQuery({ + queryKey: ['todos'], + queryFn: fetchTodos, + }) + + return { + props: { + dehydratedState: dehydrate(queryClient), + }, + } +} + +function Page({ dehydratedState }) { + return ( + + + + ) +} +``` + +### Streaming SSR (React Server Components) + +```tsx +import { dehydrate, HydrationBoundary } from '@tanstack/react-query' +import { makeQueryClient } from './query-client' + +export default async function Page() { + const queryClient = makeQueryClient() + + // Prefetch on server + await queryClient.prefetchQuery({ + queryKey: ['todos'], + queryFn: fetchTodos, + }) + + return ( + + + + ) +} +``` + +## QueryClient API + +```tsx +const queryClient = useQueryClient() + +// Get cached data +queryClient.getQueryData(['todos']) + +// Set cached data +queryClient.setQueryData(['todos'], updatedTodos) +queryClient.setQueryData(['todos'], (old) => [...old, newTodo]) + +// Get query state +queryClient.getQueryState(['todos']) + +// Check if fetching +queryClient.isFetching({ queryKey: ['todos'] }) +queryClient.isMutating() + +// Cancel queries +queryClient.cancelQueries({ queryKey: ['todos'] }) + +// Invalidate (marks stale, refetches active) +queryClient.invalidateQueries({ queryKey: ['todos'] }) + +// Refetch (force refetch even if fresh) +queryClient.refetchQueries({ queryKey: ['todos'] }) + +// Remove from cache +queryClient.removeQueries({ queryKey: ['todos'] }) + +// Reset to initial state +queryClient.resetQueries({ queryKey: ['todos'] }) + +// Clear entire cache +queryClient.clear() + +// Prefetch +queryClient.prefetchQuery({ queryKey: ['todos'], queryFn: fetchTodos }) +queryClient.ensureQueryData({ queryKey: ['todos'], queryFn: fetchTodos }) + +// Get/set defaults +queryClient.setQueryDefaults(['todos'], { staleTime: 10000 }) +queryClient.getQueryDefaults(['todos']) +queryClient.setMutationDefaults(['addTodo'], { mutationFn: addTodo }) +``` + +## Testing + +```tsx +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, // Don't retry in tests + gcTime: Infinity, // Prevent garbage collection during tests + }, + }, + }) + return ({ children }) => ( + + {children} + + ) +} + +test('fetches todos', async () => { + const { result } = renderHook(() => useQuery({ + queryKey: ['todos'], + queryFn: fetchTodos, + }), { wrapper: createWrapper() }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toEqual(expectedTodos) +}) + +// Mock with setQueryData for component tests +test('renders todos', () => { + const queryClient = new QueryClient() + queryClient.setQueryData(['todos'], mockTodos) + + render( + + + + ) + + expect(screen.getByText('Todo 1')).toBeInTheDocument() +}) +``` + +## TypeScript Patterns + +### Typing Query Functions + +```tsx +interface Todo { + id: number + title: string + completed: boolean +} + +// Type is inferred from queryFn return type +const { data } = useQuery({ + queryKey: ['todos'], + queryFn: async (): Promise => { + const res = await fetch('/api/todos') + return res.json() + }, +}) +// data: Todo[] | undefined + +// With select +const { data } = useQuery({ + queryKey: ['todos'], + queryFn: fetchTodos, + select: (data): string[] => data.map(t => t.title), +}) +// data: string[] | undefined +``` + +### Typing Errors + +```tsx +// Default error type is Error +const { error } = useQuery({ + queryKey: ['todos'], + queryFn: fetchTodos, +}) + +// Or register globally +declare module '@tanstack/react-query' { + interface Register { + defaultError: AxiosError + } +} +``` + +### Query Options Pattern (Recommended) + +```tsx +import { queryOptions, infiniteQueryOptions } from '@tanstack/react-query' + +export const todosOptions = queryOptions({ + queryKey: ['todos'] as const, + queryFn: fetchTodos, + staleTime: 5000, +}) + +export const todoOptions = (id: string) => + queryOptions({ + queryKey: ['todos', id] as const, + queryFn: () => fetchTodo(id), + enabled: !!id, + }) + +// Full type inference everywhere +const { data } = useQuery(todosOptions) +const { data } = useSuspenseQuery(todoOptions('123')) +await queryClient.ensureQueryData(todosOptions) +queryClient.invalidateQueries({ queryKey: todosOptions.queryKey }) +``` + +## Advanced Patterns + +### Window Focus Refetching + +```tsx +// Disable globally +const queryClient = new QueryClient({ + defaultOptions: { + queries: { refetchOnWindowFocus: false }, + }, +}) + +// Custom focus manager +import { focusManager } from '@tanstack/react-query' + +// For React Native +focusManager.setEventListener((handleFocus) => { + const subscription = AppState.addEventListener('change', (state) => { + handleFocus(state === 'active') + }) + return () => subscription.remove() +}) +``` + +### Network Mode + +```tsx +useQuery({ + queryKey: ['todos'], + queryFn: fetchTodos, + // 'online' (default): only fetch when online + // 'always': always fetch (useful for local-first) + // 'offlineFirst': try fetch, use cache if offline + networkMode: 'offlineFirst', +}) +``` + +### Query Cancellation + +```tsx +useQuery({ + queryKey: ['todos'], + queryFn: async ({ signal }) => { + // signal is AbortSignal - automatically cancelled on unmount or key change + const res = await fetch('/api/todos', { signal }) + return res.json() + }, +}) + +// Manual cancellation +queryClient.cancelQueries({ queryKey: ['todos'] }) +``` + +### Persistence + +```tsx +import { persistQueryClient } from '@tanstack/react-query-persist-client' +import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister' + +const persister = createSyncStoragePersister({ + storage: window.localStorage, +}) + +persistQueryClient({ + queryClient, + persister, + maxAge: 1000 * 60 * 60 * 24, // 24 hours +}) +``` + +## Best Practices + +1. **Use `queryOptions` helper** for type-safe, reusable query configurations +2. **Structure query keys hierarchically** for granular invalidation +3. **Set appropriate `staleTime`** - 0 means always refetch on mount (default), increase for less dynamic data +4. **Use `placeholderData`** (not `initialData`) for keeping previous page data during pagination +5. **Prefer `useSuspenseQuery`** when using Suspense boundaries for cleaner component code +6. **Use `enabled`** for dependent queries, not conditional hook calls +7. **Always invalidate after mutations** - don't rely solely on optimistic updates +8. **Cancel queries in `onMutate`** before optimistic updates to prevent race conditions +9. **Use `ensureQueryData`** in route loaders instead of `prefetchQuery` for immediate access +10. **Set `retry: false` in tests** to avoid timeout issues +11. **Don't destructure the query result** if you need to pass it around (breaks reactivity) +12. **Use `select`** for derived data instead of transforming in the component +13. **Keep query functions pure** - they should only fetch, not cause side effects +14. **Use `gcTime: Infinity`** in tests to prevent cache cleanup during assertions + +## Common Pitfalls + +- Using `initialData` when you mean `placeholderData` (initialData counts as "fresh" data) +- Not providing `initialPageParam` for infinite queries (required in v5) +- Calling hooks conditionally (violates React rules) +- Not cancelling queries before optimistic updates (race conditions) +- Setting `staleTime` higher than `gcTime` (data gets garbage collected while "fresh") +- Forgetting to wrap tests with `QueryClientProvider` +- Using same `QueryClient` instance across tests (shared state) +- Not awaiting `invalidateQueries` in mutation callbacks when order matters diff --git a/.claude/skills/tanstack-router/SKILL.md b/.claude/skills/tanstack-router/SKILL.md new file mode 100644 index 0000000..a4b817c --- /dev/null +++ b/.claude/skills/tanstack-router/SKILL.md @@ -0,0 +1,734 @@ +--- +name: tanstack-router +description: Type-safe routing for React and Solid applications with first-class search params, data loading, and seamless integration with the React ecosystem. +--- + + +## Overview + +TanStack Router is a fully type-safe router for React (and Solid) applications. It provides file-based routing, first-class search parameter management, built-in data loading, code splitting, and deep TypeScript integration. It serves as the routing foundation for TanStack Start (the full-stack framework). + +**Package:** `@tanstack/react-router` +**CLI:** `@tanstack/router-cli` or `@tanstack/router-plugin` (Vite/Rspack/Webpack) +**Devtools:** `@tanstack/react-router-devtools` + +## Installation + +```bash +npm install @tanstack/react-router +# For file-based routing with Vite: +npm install -D @tanstack/router-plugin +# Or standalone CLI: +npm install -D @tanstack/router-cli +``` + +## Core Concepts + +### Route Trees + +Routes are organized in a tree structure. The root route is the top-level layout, and child routes nest underneath. + +```tsx +import { createRootRoute, createRoute, createRouter } from '@tanstack/react-router' + +const rootRoute = createRootRoute({ + component: RootLayout, +}) + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: HomePage, +}) + +const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + component: AboutPage, +}) + +const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) +const router = createRouter({ routeTree }) +``` + +### File-Based Routing + +File-based routing automatically generates the route tree from your file structure. Configure with Vite plugin: + +```ts +// vite.config.ts +import { defineConfig } from 'vite' +import { TanStackRouterVite } from '@tanstack/router-plugin/vite' + +export default defineConfig({ + plugins: [ + TanStackRouterVite(), + // ... other plugins + ], +}) +``` + +#### File Naming Conventions + +| File Pattern | Route Type | Example Path | +|---|---|---| +| `__root.tsx` | Root layout | N/A (wraps all) | +| `index.tsx` | Index route | `/` | +| `about.tsx` | Static route | `/about` | +| `$postId.tsx` | Dynamic param | `/posts/$postId` | +| `posts.tsx` | Layout route | `/posts/*` (layout) | +| `posts/index.tsx` | Nested index | `/posts` | +| `posts/$postId.tsx` | Nested dynamic | `/posts/123` | +| `posts_.$postId.tsx` | Pathless layout | `/posts/123` (different layout) | +| `_layout.tsx` | Pathless layout | N/A (groups routes) | +| `_layout/dashboard.tsx` | Grouped route | `/dashboard` | +| `$.tsx` | Splat/catch-all | `/*` | +| `posts.$postId.edit.tsx` | Dot notation | `/posts/123/edit` | + +#### Special Prefixes +- `_` prefix: Pathless routes (layout groups without URL segment) +- `$` prefix: Dynamic path parameters +- `(folder)` parentheses: Route groups (organizational, no URL impact) + +### Route Configuration + +Each route can define: + +```tsx +// routes/posts.$postId.tsx +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/posts/$postId')({ + // Validation for path params + params: { + parse: (params) => ({ postId: Number(params.postId) }), + stringify: (params) => ({ postId: String(params.postId) }), + }, + + // Search params validation + validateSearch: (search: Record) => { + return { + page: Number(search.page ?? 1), + filter: (search.filter as string) || '', + } + }, + + // Data loading + loader: async ({ params, context, abortController }) => { + return fetchPost(params.postId) + }, + + // Loader dependencies (re-run loader when these change) + loaderDeps: ({ search }) => ({ page: search.page }), + + // Stale time for cached loader data + staleTime: 5_000, + + // Preloading + preloadStaleTime: 30_000, + + // Error component + errorComponent: PostErrorComponent, + + // Pending/loading component + pendingComponent: PostLoadingComponent, + + // 404 component + notFoundComponent: PostNotFoundComponent, + + // Before load hook (authentication, redirects) + beforeLoad: async ({ context, location }) => { + if (!context.auth.isAuthenticated) { + throw redirect({ + to: '/login', + search: { redirect: location.href }, + }) + } + }, + + // Head/meta management + head: () => ({ + meta: [{ title: 'Post Details' }], + }), + + // Component + component: PostComponent, +}) + +function PostComponent() { + const { postId } = Route.useParams() + const post = Route.useLoaderData() + const { page, filter } = Route.useSearch() + + return
{post.title}
+} +``` + +## Data Loading + +### Route Loaders + +```tsx +export const Route = createFileRoute('/posts')({ + loader: async ({ context }) => { + // Access router context (e.g., queryClient) + const posts = await context.queryClient.ensureQueryData({ + queryKey: ['posts'], + queryFn: fetchPosts, + }) + return { posts } + }, + component: PostsComponent, +}) + +function PostsComponent() { + const { posts } = Route.useLoaderData() + // ... +} +``` + +### Loader Dependencies + +Control when loaders re-execute: + +```tsx +export const Route = createFileRoute('/posts')({ + loaderDeps: ({ search: { page, filter } }) => ({ page, filter }), + loader: async ({ deps: { page, filter } }) => { + return fetchPosts({ page, filter }) + }, +}) +``` + +### Deferred Data Loading + +Stream non-critical data: + +```tsx +import { Await, defer } from '@tanstack/react-router' + +export const Route = createFileRoute('/dashboard')({ + loader: async () => { + const criticalData = await fetchCriticalData() + const deferredData = defer(fetchSlowData()) + return { criticalData, deferredData } + }, + component: DashboardComponent, +}) + +function DashboardComponent() { + const { criticalData, deferredData } = Route.useLoaderData() + + return ( +
+ + }> + + {(data) => } + + +
+ ) +} +``` + +### Context-Based Data Loading + +Provide shared dependencies via router context: + +```tsx +// Create router with context +const router = createRouter({ + routeTree, + context: { + queryClient, + auth: undefined!, // Will be provided by RouterProvider + }, +}) + +// In root/app component +function App() { + const auth = useAuth() + return +} + +// In routes +export const Route = createFileRoute('/protected')({ + beforeLoad: ({ context }) => { + if (!context.auth.user) throw redirect({ to: '/login' }) + }, + loader: ({ context }) => { + return context.queryClient.ensureQueryData(userQueryOptions()) + }, +}) +``` + +## Search Parameters + +### Validation + +```tsx +import { z } from 'zod' + +const postSearchSchema = z.object({ + page: z.number().default(1), + filter: z.string().default(''), + sort: z.enum(['date', 'title']).default('date'), +}) + +export const Route = createFileRoute('/posts')({ + validateSearch: postSearchSchema, + // Or manual validation: + // validateSearch: (search) => postSearchSchema.parse(search), +}) +``` + +### Reading Search Params + +```tsx +function PostsComponent() { + // From route + const { page, filter, sort } = Route.useSearch() + + // Or from any component with useSearch hook + const search = useSearch({ from: '/posts' }) +} +``` + +### Updating Search Params + +```tsx +import { useNavigate } from '@tanstack/react-router' + +function Pagination() { + const navigate = useNavigate() + const { page } = Route.useSearch() + + return ( + + ) +} + +// Or via Link component + ({ ...prev, page: 2 })} +> + Page 2 + +``` + +### Search Param Options + +```tsx +const router = createRouter({ + routeTree, + // Custom serialization + search: { + strict: true, // Reject unknown params + }, + // Default search param serializer + stringifySearch: defaultStringifySearch, + parseSearch: defaultParseSearch, +}) +``` + +## Navigation + +### Link Component + +```tsx +import { Link } from '@tanstack/react-router' + +// Static route +About + +// Dynamic route with params + + Post 123 + + +// With search params + + Page 2 + + +// Active link styling + + Posts + + +// Preloading +Posts +Dashboard + +// Hash +API Reference +``` + +### Programmatic Navigation + +```tsx +import { useNavigate, useRouter } from '@tanstack/react-router' + +function MyComponent() { + const navigate = useNavigate() + const router = useRouter() + + // Navigate to a route + navigate({ to: '/posts', search: { page: 1 } }) + + // Navigate with replace + navigate({ to: '/posts', replace: true }) + + // Relative navigation + navigate({ to: '.', search: (prev) => ({ ...prev, page: 2 }) }) + + // Go back/forward + router.history.back() + router.history.forward() + + // Invalidate and reload current route + router.invalidate() +} +``` + +### Redirects + +```tsx +import { redirect } from '@tanstack/react-router' + +// In beforeLoad or loader +throw redirect({ + to: '/login', + search: { redirect: location.href }, + // Optional status code + statusCode: 301, // Permanent redirect (SSR) +}) +``` + +### Navigation Blocking + +```tsx +import { useBlocker } from '@tanstack/react-router' + +function FormComponent() { + const [isDirty, setIsDirty] = useState(false) + + useBlocker({ + shouldBlockFn: () => isDirty, + withResolver: true, // Shows confirm dialog + }) + + // Or with custom UI + const { proceed, reset, status } = useBlocker({ + shouldBlockFn: () => isDirty, + }) + + if (status === 'blocked') { + return ( +
+

Are you sure you want to leave?

+ + +
+ ) + } +} +``` + +## Code Splitting + +### Automatic (File-Based Routing) + +With file-based routing, create a lazy file: + +``` +routes/ + posts.tsx # Critical: loader, beforeLoad, meta + posts.lazy.tsx # Lazy: component, pendingComponent, errorComponent +``` + +```tsx +// posts.tsx (loaded eagerly) +export const Route = createFileRoute('/posts')({ + loader: () => fetchPosts(), +}) + +// posts.lazy.tsx (loaded lazily) +import { createLazyFileRoute } from '@tanstack/react-router' + +export const Route = createLazyFileRoute('/posts')({ + component: PostsComponent, + pendingComponent: PostsLoading, + errorComponent: PostsError, +}) +``` + +### Manual Code Splitting + +```tsx +const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + loader: () => fetchPosts(), +}).lazy(() => import('./posts.lazy').then((d) => d.Route)) +``` + +## Preloading + +```tsx +// Router-level defaults +const router = createRouter({ + routeTree, + defaultPreload: 'intent', // 'intent' | 'viewport' | 'render' | false + defaultPreloadStaleTime: 30_000, // 30 seconds +}) + +// Route-level +export const Route = createFileRoute('/posts/$postId')({ + // Stale time for the loader data + staleTime: 5_000, + // How long preloaded data stays fresh + preloadStaleTime: 30_000, +}) + +// Link-level + + Posts + +``` + +## Type Safety + +### Register Router Type + +```tsx +// Declare module for type inference +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} +``` + +### Type-Safe Hooks + +All hooks are fully typed based on the route tree: + +```tsx +// useParams - typed to route's params +const { postId } = useParams({ from: '/posts/$postId' }) + +// useSearch - typed to route's search schema +const { page } = useSearch({ from: '/posts' }) + +// useLoaderData - typed to loader return +const data = useLoaderData({ from: '/posts/$postId' }) + +// useRouteContext - typed to route context +const { auth } = useRouteContext({ from: '/protected' }) +``` + +### Route Generics + +```tsx +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/posts/$postId')({ + // TypeScript infers: + // params: { postId: string } + // search: validated search schema type + // loaderData: return type of loader + // context: router context type +}) +``` + +## Authenticated Routes + +```tsx +// __root.tsx +export const Route = createRootRouteWithContext<{ + auth: AuthContext +}>()({ + component: RootComponent, +}) + +// _authenticated.tsx (pathless layout for auth) +export const Route = createFileRoute('/_authenticated')({ + beforeLoad: ({ context, location }) => { + if (!context.auth.isAuthenticated) { + throw redirect({ + to: '/login', + search: { redirect: location.href }, + }) + } + }, +}) + +// _authenticated/dashboard.tsx +export const Route = createFileRoute('/_authenticated/dashboard')({ + component: Dashboard, // Only accessible when authenticated +}) +``` + +## Scroll Restoration + +```tsx +const router = createRouter({ + routeTree, + // Enable scroll restoration + defaultScrollRestoration: true, +}) + +// Or per-route +export const Route = createFileRoute('/posts')({ + // Scroll to top on navigation + scrollRestoration: true, +}) + +// Custom scroll restoration key + location.pathname} +/> +``` + +## Route Masking + +Display a different URL than the actual route: + +```tsx + + View Photo + + +// Or programmatically +navigate({ + to: '/photos/$photoId', + params: { photoId: photo.id }, + mask: { to: '/photos', search: { photoId: photo.id } }, +}) +``` + +## Not Found Handling + +```tsx +// Global 404 +const router = createRouter({ + routeTree, + defaultNotFoundComponent: () =>
Page not found
, +}) + +// Route-level 404 +export const Route = createFileRoute('/posts/$postId')({ + loader: async ({ params }) => { + const post = await fetchPost(params.postId) + if (!post) throw notFound() + return post + }, + notFoundComponent: () =>
Post not found
, +}) +``` + +## Head Management + +```tsx +export const Route = createFileRoute('/posts/$postId')({ + head: ({ loaderData }) => ({ + meta: [ + { title: loaderData.title }, + { name: 'description', content: loaderData.excerpt }, + { property: 'og:title', content: loaderData.title }, + ], + links: [ + { rel: 'canonical', href: `https://example.com/posts/${loaderData.id}` }, + ], + }), +}) +``` + +## Integration with TanStack Query + +```tsx +import { queryOptions } from '@tanstack/react-query' + +const postsQueryOptions = queryOptions({ + queryKey: ['posts'], + queryFn: fetchPosts, +}) + +export const Route = createFileRoute('/posts')({ + loader: ({ context: { queryClient } }) => { + // Ensure data is in cache, won't refetch if fresh + return queryClient.ensureQueryData(postsQueryOptions) + }, + component: PostsComponent, +}) + +function PostsComponent() { + // Use the same query options for reactive updates + const { data: posts } = useSuspenseQuery(postsQueryOptions) + return +} +``` + +## Router Hooks Reference + +| Hook | Purpose | +|------|---------| +| `useRouter()` | Access router instance | +| `useRouterState()` | Subscribe to router state | +| `useParams()` | Get route path params | +| `useSearch()` | Get validated search params | +| `useLoaderData()` | Get route loader data | +| `useRouteContext()` | Get route context | +| `useNavigate()` | Get navigate function | +| `useLocation()` | Get current location | +| `useMatches()` | Get all matched routes | +| `useMatch()` | Get specific route match | +| `useBlocker()` | Block navigation | +| `useLinkProps()` | Get link props for custom components | +| `useMatchRoute()` | Check if a route matches | + +## Best Practices + +1. **Use file-based routing** for most applications - it's simpler and auto-generates the route tree +2. **Validate search params** with Zod or custom validators for type safety +3. **Use `loaderDeps`** to control when loaders re-execute based on search param changes +4. **Leverage context** for dependency injection (QueryClient, auth state) +5. **Use `beforeLoad`** for authentication guards, not in components +6. **Separate critical vs lazy code** - keep loaders in the main file, components in `.lazy.tsx` +7. **Use `preload="intent"`** on Links for perceived performance +8. **Use `staleTime`** to prevent unnecessary refetches during navigation +9. **Register the router type** for full TypeScript inference across the app +10. **Use `notFound()`** instead of conditional rendering for 404 states +11. **Colocate search param logic** with routes that own them +12. **Use pathless layouts** (`_authenticated`) for shared auth/layout logic without URL segments + +## Common Pitfalls + +- Forgetting to register the router type (`declare module`) +- Not using `loaderDeps` when loader depends on search params (causes stale data) +- Putting auth checks in components instead of `beforeLoad` (flash of protected content) +- Not handling the loading state with `pendingComponent` +- Using `useEffect` for data fetching instead of route loaders +- Mutating search params directly instead of using navigate/Link +- Not wrapping the app with `RouterProvider` +- Forgetting `getParentRoute` in code-based route definitions diff --git a/.claude/skills/tanstack-start/SKILL.md b/.claude/skills/tanstack-start/SKILL.md new file mode 100644 index 0000000..2d34fab --- /dev/null +++ b/.claude/skills/tanstack-start/SKILL.md @@ -0,0 +1,250 @@ +--- +name: tanstack-start +description: Full-stack React framework powered by TanStack Router with SSR, streaming, server functions, and deployment to any hosting provider. +--- + +# TanStack Start Skills + +## Overview + +TanStack Start is a full-stack React framework built on TanStack Router, powered by Vite and Nitro (via Vinxi). It provides server-side rendering, streaming, server functions (RPC), middleware, API routes, and deploys to any platform via Nitro presets. + +**Package:** `@tanstack/react-start` +**Router Plugin:** `@tanstack/router-plugin` +**Build Tool:** Vinxi (Vite + Nitro) +**Status:** RC (Release Candidate) +**RSC Support:** React Server Components support is in active development and will land as a non-breaking v1.x addition + +## Installation & Project Setup + +```bash +npx @tanstack/cli create my-app +# Or manually: +npm install @tanstack/react-start @tanstack/react-router react react-dom +npm install -D @tanstack/router-plugin typescript vite vite-tsconfig-paths +``` + +### Project Structure + +``` +my-app/ + app/ + routes/ + __root.tsx # Root layout + index.tsx # / route + posts.$postId.tsx # /posts/:postId + api/ + users.ts # /api/users API route + client.tsx # Client entry + router.tsx # Router creation + ssr.tsx # SSR entry + routeTree.gen.ts # Auto-generated route tree + app.config.ts # TanStack Start config + tsconfig.json + package.json +``` + +### Configuration (`app.config.ts`) + +```typescript +import { defineConfig } from '@tanstack/react-start/config' +import viteTsConfigPaths from 'vite-tsconfig-paths' + +export default defineConfig({ + vite: { + plugins: [ + viteTsConfigPaths({ projects: ['./tsconfig.json'] }), + ], + }, + server: { + preset: 'node-server', // 'vercel' | 'netlify' | 'cloudflare-pages' | etc. + }, + tsr: { + appDirectory: './app', + routesDirectory: './app/routes', + generatedRouteTree: './app/routeTree.gen.ts', + }, +}) +``` + +## Server Functions (`createServerFn`) + +Server functions provide type-safe RPC calls between client and server. + +### Basic Server Functions + +```typescript +import { createServerFn } from '@tanstack/react-start' + +// GET (data fetching, cacheable) +const getUsers = createServerFn() + .handler(async () => { + const users = await db.query.users.findMany() + return users + }) + +// POST (mutations, side effects) +const createUser = createServerFn({ method: 'POST' }) + .validator((data: { name: string; email: string }) => data) + .handler(async ({ data }) => { + const user = await db.insert(users).values(data).returning() + return user + }) +``` + +### With Zod Validation + +```typescript +import { z } from 'zod' + +const updateUser = createServerFn({ method: 'POST' }) + .validator( + z.object({ + id: z.string(), + name: z.string().min(1), + email: z.string().email(), + }) + ) + .handler(async ({ data }) => { + // data is fully typed: { id: string; name: string; email: string } + return await db.update(users).set(data).where(eq(users.id, data.id)) + }) +``` + +## Middleware + +### Creating Middleware + +```typescript +import { createMiddleware } from '@tanstack/react-start' + +const loggingMiddleware = createMiddleware().handler(async ({ next }) => { + console.log('Request started') + const result = await next() + console.log('Request completed') + return result +}) +``` + +### Auth Middleware with Context + +```typescript +const authMiddleware = createMiddleware().handler(async ({ next }) => { + const request = getWebRequest() + const session = await getSession(request) + + if (!session?.user) { + throw redirect({ to: '/login' }) + } + + // Pass typed context to handler + return next({ context: { user: session.user } }) +}) +``` + +### Chaining Middleware + +```typescript +const adminMiddleware = createMiddleware() + .middleware([authMiddleware]) + .handler(async ({ next, context }) => { + // context.user is typed from authMiddleware + if (context.user.role !== 'admin') { + throw redirect({ to: '/unauthorized' }) + } + return next({ context: { isAdmin: true } }) + }) + +// Usage +const adminAction = createServerFn({ method: 'POST' }) + .middleware([adminMiddleware]) + .handler(async ({ context }) => { + // context: { user: User; isAdmin: boolean } + return { success: true } + }) +``` + +## API Routes (Server Routes) + +```typescript +// app/routes/api/users.ts +import { createAPIFileRoute } from '@tanstack/react-start/api' + +export const APIRoute = createAPIFileRoute('/api/users')({ + GET: async ({ request }) => { + const users = await db.query.users.findMany() + return Response.json(users) + }, + POST: async ({ request }) => { + const body = await request.json() + const user = await db.insert(users).values(body).returning() + return new Response(JSON.stringify(user), { status: 201 }) + }, +}) +``` + +## SSR Strategies + +### Streaming SSR (Default) + +```typescript +export const Route = createFileRoute('/dashboard')({ + loader: async () => ({ + criticalData: await fetchCriticalData(), + deferredData: defer(fetchSlowData()), + }), + component: Dashboard, +}) + +function Dashboard() { + const { criticalData, deferredData } = Route.useLoaderData() + return ( +
+ + }> + + {(data) => } + + +
+ ) +} +``` + +## Deployment + +### Supported Platforms (Nitro Presets) + +```typescript +// app.config.ts +export default defineConfig({ + server: { + preset: 'node-server', // Self-hosted Node.js + // preset: 'vercel', // Vercel + // preset: 'netlify', // Netlify + // preset: 'cloudflare-pages', // Cloudflare Pages + // preset: 'aws-lambda', // AWS Lambda + // preset: 'deno-server', // Deno Deploy + // preset: 'bun', // Bun + }, +}) +``` + +## Best Practices + +1. **Use validators for all server function inputs** - runtime safety and TypeScript inference +2. **Compose middleware** for cross-cutting concerns (auth, logging, rate limiting) +3. **Use `createServerFn` GET** for data fetching (cacheable, preloadable) +4. **Use `createServerFn` POST** for mutations and side effects +5. **Use `beforeLoad`** for route-level auth guards +6. **Use `defer()`** for non-critical data to improve TTFB +7. **Set `defaultPreload: 'intent'`** on the router for instant navigation +8. **Co-locate server functions** with the routes that use them + +## Common Pitfalls + +- Server functions cannot close over client-side variables (they're extracted to separate bundles) +- Data returned from server functions must be serializable +- Forgetting `await` in loaders leads to streaming issues +- Importing server-only code in client bundles causes build errors +- Missing `declare module '@tanstack/react-router'` loses all type safety diff --git a/.claude/skills/tanstack-store/SKILL.md b/.claude/skills/tanstack-store/SKILL.md new file mode 100644 index 0000000..c1e289b --- /dev/null +++ b/.claude/skills/tanstack-store/SKILL.md @@ -0,0 +1,305 @@ +--- +name: tanstack-store +description: Framework-agnostic, immutable reactive data store with framework adapters for React, Vue, Solid, Angular, and Svelte. +--- + + +## Overview + +TanStack Store is a lightweight reactive store (signals-like) that powers the internals of TanStack libraries. It provides `Store` for state, `Derived` for computed values, `Effect` for side effects, and `batch` for atomic updates. Framework adapters provide reactive hooks. + +**Core:** `@tanstack/store` +**React:** `@tanstack/react-store` + +## Installation + +```bash +npm install @tanstack/store @tanstack/react-store +``` + +## Store + +### Creating a Store + +```typescript +import { Store } from '@tanstack/store' + +const countStore = new Store(0) + +const userStore = new Store<{ name: string; email: string }>({ + name: 'Alice', + email: 'alice@example.com', +}) +``` + +### Updating State + +```typescript +// Function updater (immutable update) +countStore.setState((prev) => prev + 1) + +userStore.setState((prev) => ({ ...prev, name: 'Bob' })) +``` + +### Subscribing to Changes + +```typescript +const unsub = countStore.subscribe(() => { + console.log('Count:', countStore.state) +}) + +// Cleanup +unsub() +``` + +### Store Options + +```typescript +const store = new Store(initialState, { + // Custom update function + updateFn: (prevValue) => (updater) => { + return updater(prevValue) // custom logic + }, + // Callback on subscribe + onSubscribe: (listener, store) => { + console.log('New subscriber') + return () => console.log('Unsubscribed') + }, + // Callback on every update + onUpdate: () => { + console.log('State updated:', store.state) + }, +}) +``` + +### Store Properties + +```typescript +store.state // Current state +store.prevState // Previous state +store.listeners // Set of listener callbacks +``` + +## Derived (Computed Values) + +```typescript +import { Store, Derived } from '@tanstack/store' + +const count = new Store(5) +const multiplier = new Store(2) + +const doubled = new Derived({ + deps: [count, multiplier], + fn: ({ currDepVals }) => currDepVals[0] * currDepVals[1], +}) + +// MUST mount to activate +const unmount = doubled.mount() + +console.log(doubled.state) // 10 + +count.setState(() => 10) +console.log(doubled.state) // 20 + +// Cleanup +unmount() +``` + +### Derived with Previous Value + +```typescript +const accumulated = new Derived({ + deps: [count], + fn: ({ prevVal, currDepVals }) => { + return currDepVals[0] + (prevVal ?? 0) + }, +}) +``` + +### Chaining Derived + +```typescript +const filtered = new Derived({ + deps: [dataStore, filterStore], + fn: ({ currDepVals }) => currDepVals[0].filter(matchesFilter(currDepVals[1])), +}) + +const sorted = new Derived({ + deps: [filtered, sortStore], + fn: ({ currDepVals }) => [...currDepVals[0]].sort(comparator(currDepVals[1])), +}) + +const paginated = new Derived({ + deps: [sorted, pageStore], + fn: ({ currDepVals }) => currDepVals[0].slice( + currDepVals[1].offset, + currDepVals[1].offset + currDepVals[1].limit, + ), +}) +``` + +## Effect (Side Effects) + +```typescript +import { Store, Effect } from '@tanstack/store' + +const count = new Store(0) + +const logger = new Effect({ + deps: [count], + fn: () => { + console.log('Count changed:', count.state) + // Optionally return cleanup function + return () => console.log('Cleaning up') + }, + eager: false, // true = run immediately on mount +}) + +const unmount = logger.mount() + +count.setState(() => 1) // logs: "Count changed: 1" + +unmount() +``` + +### Effect with Cleanup + +```typescript +const timerEffect = new Effect({ + deps: [intervalStore], + fn: () => { + const id = setInterval(() => { /* ... */ }, intervalStore.state) + return () => clearInterval(id) // cleanup on next run or unmount + }, +}) +``` + +## Batch + +Group multiple updates into one notification: + +```typescript +import { batch } from '@tanstack/store' + +// Subscribers fire only once with final state +batch(() => { + countStore.setState(() => 1) + nameStore.setState(() => 'Alice') + settingsStore.setState((prev) => ({ ...prev, theme: 'dark' })) +}) +``` + +## React Integration + +### useStore Hook + +```tsx +import { useStore } from '@tanstack/react-store' + +// Subscribe to full state +function Counter() { + const count = useStore(countStore) + return +} + +// Subscribe with selector (performance optimization) +function UserName() { + const name = useStore(userStore, (state) => state.name) + return {name} +} + +// Subscribe to Derived +function DoubledDisplay() { + const value = useStore(doubledDerived) + return {value} +} +``` + +### shallow Equality Function + +Prevents re-renders when selector returns structurally-equal objects: + +```tsx +import { useStore } from '@tanstack/react-store' +import { shallow } from '@tanstack/react-store' + +function TodoList() { + // Without shallow: re-renders on ANY state change (new object ref) + // With shallow: only re-renders when items actually change + const items = useStore(todosStore, (state) => state.items, shallow) + return
    {items.map(/* ... */)}
+} +``` + +### Mounting Derived/Effect in React + +```tsx +function MyComponent() { + useEffect(() => { + const unmountDerived = myDerived.mount() + const unmountEffect = myEffect.mount() + return () => { + unmountDerived() + unmountEffect() + } + }, []) + + const value = useStore(myDerived) + return {value} +} +``` + +## Module-Level Store Pattern + +```typescript +// stores/counter.ts +import { Store, Derived } from '@tanstack/store' + +export const counterStore = new Store(0) + +export const doubledCount = new Derived({ + deps: [counterStore], + fn: ({ currDepVals }) => currDepVals[0] * 2, +}) + +// Actions as plain functions +export function increment() { + counterStore.setState((c) => c + 1) +} + +export function reset() { + counterStore.setState(() => 0) +} +``` + +## Framework Adapters + +| Framework | Package | Hook/Composable | +|-----------|---------|-----------------| +| React | `@tanstack/react-store` | `useStore(store, selector?, equalityFn?)` | +| Vue | `@tanstack/vue-store` | `useStore(store, selector?)` (returns computed ref) | +| Solid | `@tanstack/solid-store` | `useStore(store, selector?)` (returns signal) | +| Angular | `@tanstack/angular-store` | `injectStore(store, selector?)` (returns signal) | +| Svelte | `@tanstack/svelte-store` | `useStore(store, selector?)` (returns $state) | + +## Best Practices + +1. **Define stores at module level** - they're singletons +2. **Use selectors** in `useStore` to prevent unnecessary re-renders +3. **Use `shallow`** when selectors return objects/arrays +4. **Always call `mount()`** on Derived and Effect instances +5. **Always clean up** unmount functions (especially in React useEffect) +6. **Never mutate state directly** - always use `setState` +7. **Use `batch`** for multiple related updates +8. **Use Derived chains** for data transformations (filter -> sort -> paginate) +9. **Return cleanup functions** from Effect `fn` for timers/listeners +10. **Select primitives** when possible (no equality fn needed) + +## Common Pitfalls + +- Forgetting to `mount()` Derived/Effect (they won't activate) +- Not cleaning up subscriptions/unmount functions (memory leaks) +- Mutating `store.state` directly instead of using `setState` +- Creating new object references in selectors without `shallow` +- Using `useStore` without a selector (subscribes to everything) +- Forgetting `eager: true` when Effect should run immediately diff --git a/.claude/skills/tanstack-table/SKILL.md b/.claude/skills/tanstack-table/SKILL.md new file mode 100644 index 0000000..3159adb --- /dev/null +++ b/.claude/skills/tanstack-table/SKILL.md @@ -0,0 +1,582 @@ +--- +name: tanstack-table +description: Headless UI for building powerful tables & datagrids for TS/JS, React, Vue, Solid, Svelte, Qwik, Angular, and Lit. +--- + + +## Overview + +TanStack Table is a headless UI library for building data tables and datagrids. It provides logic for sorting, filtering, pagination, grouping, expanding, column pinning/ordering/visibility/resizing, and row selection - without rendering any markup or styles. + +**Package:** `@tanstack/react-table` +**Utilities:** `@tanstack/match-sorter-utils` (fuzzy filtering) +**Current Version:** v8 + +## Installation + +```bash +npm install @tanstack/react-table +``` + +## Core Architecture + +### Building Blocks + +1. **Column Definitions** - describe columns (data access, rendering, features) +2. **Table Instance** - central coordinator with state and APIs +3. **Row Models** - data processing pipeline (filter -> sort -> group -> paginate) +4. **Headers, Rows, Cells** - renderable units + +### Critical: Data & Column Stability + +```typescript +// WRONG - new references every render, causes infinite loops +const table = useReactTable({ + data: fetchedData.results, // new ref! + columns: [{ accessorKey: 'name' }], // new ref! +}) + +// CORRECT - stable references +const columns = useMemo(() => [...], []) +const data = useMemo(() => fetchedData?.results ?? [], [fetchedData]) + +const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() }) +``` + +## Column Definitions + +### Using createColumnHelper (Recommended) + +```typescript +import { createColumnHelper } from '@tanstack/react-table' + +type Person = { + firstName: string + lastName: string + age: number + status: 'active' | 'inactive' +} + +const columnHelper = createColumnHelper() + +const columns = [ + // Accessor column (data column) + columnHelper.accessor('firstName', { + header: 'First Name', + cell: info => info.getValue(), + footer: info => info.column.id, + }), + + // Accessor with function + columnHelper.accessor(row => row.lastName, { + id: 'lastName', // required with accessorFn + header: () => Last Name, + cell: info => {info.getValue()}, + }), + + // Display column (no data, custom rendering) + columnHelper.display({ + id: 'actions', + header: 'Actions', + cell: ({ row }) => ( + + ), + }), + + // Group column (nested headers) + columnHelper.group({ + id: 'info', + header: 'Info', + columns: [ + columnHelper.accessor('age', { header: 'Age' }), + columnHelper.accessor('status', { header: 'Status' }), + ], + }), +] +``` + +### Column Options + +| Option | Type | Description | +|--------|------|-------------| +| `id` | `string` | Unique identifier (auto-derived from accessorKey) | +| `accessorKey` | `string` | Dot-notation path to row data | +| `accessorFn` | `(row) => any` | Custom accessor function | +| `header` | `string \| (context) => ReactNode` | Header renderer | +| `cell` | `(context) => ReactNode` | Cell renderer | +| `footer` | `(context) => ReactNode` | Footer renderer | +| `size` | `number` | Default width (default: 150) | +| `minSize` | `number` | Min width (default: 20) | +| `maxSize` | `number` | Max width | +| `enableSorting` | `boolean` | Enable sorting | +| `sortingFn` | `string \| SortingFn` | Sort function | +| `enableFiltering` | `boolean` | Enable filtering | +| `filterFn` | `string \| FilterFn` | Filter function | +| `enableGrouping` | `boolean` | Enable grouping | +| `aggregationFn` | `string \| AggregationFn` | Aggregation function | +| `enableHiding` | `boolean` | Enable visibility toggle | +| `enableResizing` | `boolean` | Enable resizing | +| `enablePinning` | `boolean` | Enable pinning | +| `meta` | `any` | Custom metadata | + +## Table Instance + +### Creating a Table + +```typescript +import { + useReactTable, + getCoreRowModel, + getSortedRowModel, + getFilteredRowModel, + getPaginationRowModel, + flexRender, +} from '@tanstack/react-table' + +function MyTable() { + const [sorting, setSorting] = useState([]) + const [columnFilters, setColumnFilters] = useState([]) + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }) + + const table = useReactTable({ + data, + columns, + state: { sorting, columnFilters, pagination }, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }) + + return ( + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + ))} + + ))} + + + {table.getRowModel().rows.map(row => ( + + {row.getVisibleCells().map(cell => ( + + ))} + + ))} + +
+ {header.isPlaceholder ? null : + flexRender(header.column.columnDef.header, header.getContext())} + {{ asc: ' ↑', desc: ' ↓' }[header.column.getIsSorted() as string] ?? null} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ ) +} +``` + +## Sorting + +```typescript +const table = useReactTable({ + state: { sorting }, + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + enableSorting: true, + enableMultiSort: true, + // manualSorting: true, // For server-side sorting +}) + +// Built-in sort functions: 'alphanumeric', 'text', 'datetime', 'basic' +// Column-level: sortingFn: 'alphanumeric' +``` + +## Filtering + +### Column Filtering + +```typescript +const table = useReactTable({ + state: { columnFilters }, + onColumnFiltersChange: setColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + getFacetedMinMaxValues: getFacetedMinMaxValues(), +}) + +// Built-in: 'includesString', 'equalsString', 'arrIncludes', 'inNumberRange', etc. + +// Filter UI +function Filter({ column }) { + return ( + column.setFilterValue(e.target.value)} + placeholder={`Filter... (${column.getFacetedUniqueValues()?.size})`} + /> + ) +} +``` + +### Global Filtering + +```typescript +const [globalFilter, setGlobalFilter] = useState('') + +const table = useReactTable({ + state: { globalFilter }, + onGlobalFilterChange: setGlobalFilter, + globalFilterFn: 'includesString', + getFilteredRowModel: getFilteredRowModel(), +}) +``` + +### Fuzzy Filtering + +```typescript +import { rankItem } from '@tanstack/match-sorter-utils' + +const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + const itemRank = rankItem(row.getValue(columnId), value) + addMeta({ itemRank }) + return itemRank.passed +} + +const table = useReactTable({ + filterFns: { fuzzy: fuzzyFilter }, + globalFilterFn: 'fuzzy', +}) +``` + +## Pagination + +```typescript +const table = useReactTable({ + state: { pagination }, + onPaginationChange: setPagination, + getPaginationRowModel: getPaginationRowModel(), + // For server-side: + // manualPagination: true, + // pageCount: serverPageCount, +}) + +// Navigation +table.nextPage() +table.previousPage() +table.firstPage() +table.lastPage() +table.setPageSize(20) +table.getCanNextPage() // boolean +table.getCanPreviousPage() // boolean +table.getPageCount() // total pages +``` + +## Row Selection + +```typescript +const [rowSelection, setRowSelection] = useState({}) + +const table = useReactTable({ + state: { rowSelection }, + onRowSelectionChange: setRowSelection, + enableRowSelection: true, + enableMultiRowSelection: true, +}) + +// Checkbox column +columnHelper.display({ + id: 'select', + header: ({ table }) => ( + + ), + cell: ({ row }) => ( + + ), +}) + +// Get selected rows +table.getSelectedRowModel().rows +``` + +## Column Visibility + +```typescript +const [columnVisibility, setColumnVisibility] = useState({}) + +const table = useReactTable({ + state: { columnVisibility }, + onColumnVisibilityChange: setColumnVisibility, +}) + +// Toggle UI +{table.getAllLeafColumns().map(column => ( + +))} +``` + +## Column Pinning + +```typescript +const [columnPinning, setColumnPinning] = useState({ + left: ['select', 'name'], + right: ['actions'], +}) + +const table = useReactTable({ + state: { columnPinning }, + onColumnPinningChange: setColumnPinning, + enableColumnPinning: true, +}) + +// Render pinned sections separately +row.getLeftVisibleCells() // Left-pinned +row.getCenterVisibleCells() // Unpinned +row.getRightVisibleCells() // Right-pinned +``` + +## Column Resizing + +```typescript +const table = useReactTable({ + enableColumnResizing: true, + columnResizeMode: 'onChange', // 'onChange' | 'onEnd' + defaultColumn: { size: 150, minSize: 50, maxSize: 500 }, +}) + +// Resize handle in header +
+``` + +## Grouping & Aggregation + +```typescript +const [grouping, setGrouping] = useState([]) + +const table = useReactTable({ + state: { grouping }, + onGroupingChange: setGrouping, + getGroupedRowModel: getGroupedRowModel(), + getExpandedRowModel: getExpandedRowModel(), +}) + +// Built-in aggregation: 'sum', 'min', 'max', 'mean', 'median', 'count', 'unique', 'uniqueCount' +columnHelper.accessor('amount', { + aggregationFn: 'sum', + aggregatedCell: ({ getValue }) => `Total: ${getValue()}`, +}) +``` + +## Row Expanding + +```typescript +const [expanded, setExpanded] = useState({}) + +const table = useReactTable({ + state: { expanded }, + onExpandedChange: setExpanded, + getExpandedRowModel: getExpandedRowModel(), + getSubRows: (row) => row.subRows, // For hierarchical data +}) + +// Expand toggle + + +// Detail row pattern +{row.getIsExpanded() && ( + + + + + +)} +``` + +## Virtualization Integration + +```typescript +import { useVirtualizer } from '@tanstack/react-virtual' + +function VirtualizedTable() { + const table = useReactTable({ /* ... */ }) + const { rows } = table.getRowModel() + const parentRef = useRef(null) + + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 35, + overscan: 10, + }) + + return ( +
+ + + {virtualizer.getVirtualItems().map(virtualRow => { + const row = rows[virtualRow.index] + return ( + + {row.getVisibleCells().map(cell => ( + + ))} + + ) + })} + +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ ) +} +``` + +## Server-Side Operations + +```typescript +const table = useReactTable({ + data: serverData, + columns, + manualSorting: true, + manualFiltering: true, + manualPagination: true, + pageCount: serverPageCount, + state: { sorting, columnFilters, pagination }, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + // Do NOT include getSortedRowModel, getFilteredRowModel, getPaginationRowModel +}) + +// Fetch data based on state +useEffect(() => { + fetchData({ sorting, filters: columnFilters, pagination }) +}, [sorting, columnFilters, pagination]) +``` + +## TypeScript Patterns + +### Extending Column Meta + +```typescript +declare module '@tanstack/react-table' { + interface ColumnMeta { + filterVariant?: 'text' | 'range' | 'select' + align?: 'left' | 'center' | 'right' + } +} +``` + +### Custom Filter/Sort Function Registration + +```typescript +declare module '@tanstack/react-table' { + interface FilterFns { + fuzzy: FilterFn + } + interface SortingFns { + myCustomSort: SortingFn + } +} +``` + +### Editable Cells via Table Meta + +```typescript +declare module '@tanstack/react-table' { + interface TableMeta { + updateData: (rowIndex: number, columnId: string, value: unknown) => void + } +} + +const table = useReactTable({ + meta: { + updateData: (rowIndex, columnId, value) => { + setData(old => old.map((row, i) => + i === rowIndex ? { ...row, [columnId]: value } : row + )) + }, + }, +}) +``` + +## Key Imports + +```typescript +import { + createColumnHelper, flexRender, useReactTable, + getCoreRowModel, getSortedRowModel, getFilteredRowModel, + getPaginationRowModel, getGroupedRowModel, getExpandedRowModel, + getFacetedRowModel, getFacetedUniqueValues, getFacetedMinMaxValues, +} from '@tanstack/react-table' + +import type { + ColumnDef, SortingState, ColumnFiltersState, VisibilityState, + PaginationState, ExpandedState, RowSelectionState, GroupingState, + ColumnOrderState, ColumnPinningState, FilterFn, SortingFn, +} from '@tanstack/react-table' +``` + +## Best Practices + +1. **Always memoize `data` and `columns`** to prevent infinite re-renders +2. **Use `flexRender`** for all header/cell/footer rendering +3. **Use `table.getRowModel().rows`** for final rendered rows (not getCoreRowModel) +4. **Import only needed row models** - each adds processing to the pipeline +5. **Use `getRowId`** for stable row keys when data has unique IDs +6. **Use `manualX` options** for server-side operations +7. **Pair controlled state** with both `state.X` and `onXChange` +8. **Use module augmentation** for custom meta, filter fns, sort fns +9. **Use column helper** for type-safe column definitions +10. **Set `autoResetPageIndex: true`** when filtering should reset pagination + +## Common Pitfalls + +- Defining columns inline (creates new ref each render) +- Forgetting `getCoreRowModel()` (required for all tables) +- Using row models without importing them +- Not providing `id` when using `accessorFn` +- Mixing `manualPagination` with client-side `getPaginationRowModel` +- Forgetting `colSpan` for grouped headers +- Not handling `header.isPlaceholder` for group column spacers diff --git a/.claude/skills/tanstack-virtual/SKILL.md b/.claude/skills/tanstack-virtual/SKILL.md new file mode 100644 index 0000000..ec6401e --- /dev/null +++ b/.claude/skills/tanstack-virtual/SKILL.md @@ -0,0 +1,369 @@ +--- +name: tanstack-virtual +description: Headless UI for virtualizing large element lists at 60FPS in TS/JS, React, Vue, Solid, Svelte, Lit & Angular. +--- + + +## Overview + +TanStack Virtual provides virtualization logic for rendering only visible items in large lists, grids, and tables. It calculates which items are in the viewport and positions them with absolute positioning, keeping DOM node count minimal regardless of dataset size. + +**Package:** `@tanstack/react-virtual` +**Core:** `@tanstack/virtual-core` (framework-agnostic) + +## Installation + +```bash +npm install @tanstack/react-virtual +``` + +## Core Pattern + +```tsx +import { useVirtualizer } from '@tanstack/react-virtual' + +function VirtualList() { + const parentRef = useRef(null) + + const virtualizer = useVirtualizer({ + count: 10000, + getScrollElement: () => parentRef.current, + estimateSize: () => 35, // estimated row height in px + overscan: 5, + }) + + return ( +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => ( +
+ Row {virtualItem.index} +
+ ))} +
+
+ ) +} +``` + +## Virtualizer Options + +### Required + +| Option | Type | Description | +|--------|------|-------------| +| `count` | `number` | Total number of items | +| `getScrollElement` | `() => Element \| null` | Returns scroll container | +| `estimateSize` | `(index) => number` | Estimated item size (overestimate recommended) | + +### Optional + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `overscan` | `number` | `1` | Extra items rendered beyond viewport | +| `horizontal` | `boolean` | `false` | Horizontal virtualization | +| `gap` | `number` | `0` | Gap between items (px) | +| `lanes` | `number` | `1` | Number of lanes (masonry/grid) | +| `paddingStart` | `number` | `0` | Padding before first item | +| `paddingEnd` | `number` | `0` | Padding after last item | +| `scrollPaddingStart` | `number` | `0` | Offset for scrollTo positioning | +| `scrollPaddingEnd` | `number` | `0` | Offset for scrollTo positioning | +| `initialOffset` | `number` | `0` | Starting scroll position | +| `initialRect` | `Rect` | - | Initial dimensions (SSR) | +| `enabled` | `boolean` | `true` | Enable/disable | +| `getItemKey` | `(index) => Key` | `(i) => i` | Stable key for items | +| `rangeExtractor` | `(range) => number[]` | default | Custom visible indices | +| `scrollToFn` | `(offset, options, instance) => void` | default | Custom scroll behavior | +| `measureElement` | `(el, entry, instance) => number` | default | Custom measurement | +| `onChange` | `(instance, sync) => void` | - | State change callback | +| `isScrollingResetDelay` | `number` | `150` | Delay before scroll complete | + +## Virtualizer API + +```typescript +// Get visible items +virtualizer.getVirtualItems(): VirtualItem[] + +// Get total scrollable size +virtualizer.getTotalSize(): number + +// Scroll to specific index +virtualizer.scrollToIndex(index, { align: 'start' | 'center' | 'end' | 'auto', behavior: 'auto' | 'smooth' }) + +// Scroll to offset +virtualizer.scrollToOffset(offset, options) + +// Force recalculation +virtualizer.measure() +``` + +## VirtualItem Properties + +```typescript +interface VirtualItem { + key: Key // Unique key + index: number // Index in source data + start: number // Pixel offset (use for transform) + end: number // End pixel offset + size: number // Item dimension + lane: number // Lane index (multi-column) +} +``` + +## Dynamic/Variable Heights + +Use `measureElement` ref for items with unknown heights: + +```tsx +const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 50, // overestimate +}) + +{virtualizer.getVirtualItems().map((virtualItem) => ( +
+ {items[virtualItem.index].content} +
+))} +``` + +## Horizontal Virtualization + +```tsx +const virtualizer = useVirtualizer({ + count: columns.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 100, + horizontal: true, +}) + +// Use width for container, translateX for positioning +
+ {virtualizer.getVirtualItems().map((item) => ( +
+ Column {item.index} +
+ ))} +
+``` + +## Grid Virtualization (Two Virtualizers) + +```tsx +function VirtualGrid() { + const parentRef = useRef(null) + + const rowVirtualizer = useVirtualizer({ + count: 10000, + getScrollElement: () => parentRef.current, + estimateSize: () => 35, + overscan: 5, + }) + + const columnVirtualizer = useVirtualizer({ + count: 10000, + getScrollElement: () => parentRef.current, + estimateSize: () => 100, + horizontal: true, + overscan: 5, + }) + + return ( +
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => ( + + {columnVirtualizer.getVirtualItems().map((virtualColumn) => ( +
+ Cell {virtualRow.index},{virtualColumn.index} +
+ ))} +
+ ))} +
+
+ ) +} +``` + +## Window Scrolling + +```tsx +import { useWindowVirtualizer } from '@tanstack/react-virtual' + +function WindowList() { + const listRef = useRef(null) + + const virtualizer = useWindowVirtualizer({ + count: 10000, + estimateSize: () => 45, + overscan: 5, + scrollMargin: listRef.current?.offsetTop ?? 0, + }) + + return ( +
+
+ {virtualizer.getVirtualItems().map((item) => ( +
+ Row {item.index} +
+ ))} +
+
+ ) +} +``` + +## Infinite Scrolling + +```tsx +import { useVirtualizer } from '@tanstack/react-virtual' +import { useInfiniteQuery } from '@tanstack/react-query' + +function InfiniteList() { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ + queryKey: ['items'], + queryFn: ({ pageParam = 0 }) => fetchItems(pageParam), + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + + const allItems = data?.pages.flatMap((page) => page.items) ?? [] + + const virtualizer = useVirtualizer({ + count: hasNextPage ? allItems.length + 1 : allItems.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 50, + overscan: 5, + }) + + useEffect(() => { + const items = virtualizer.getVirtualItems() + const lastItem = items[items.length - 1] + if (lastItem && lastItem.index >= allItems.length - 1 && hasNextPage && !isFetchingNextPage) { + fetchNextPage() + } + }, [virtualizer.getVirtualItems(), hasNextPage, isFetchingNextPage, allItems.length]) + + // Render virtual items, show loader row for last item if loading +} +``` + +## Sticky Items + +```tsx +import { defaultRangeExtractor, Range } from '@tanstack/react-virtual' + +const stickyIndexes = [0, 10, 20, 30] // Header indices + +const virtualizer = useVirtualizer({ + count: 1000, + getScrollElement: () => parentRef.current, + estimateSize: () => 50, + rangeExtractor: useCallback((range: Range) => { + const next = new Set([...stickyIndexes, ...defaultRangeExtractor(range)]) + return [...next].sort((a, b) => a - b) + }, [stickyIndexes]), +}) + +// Render sticky items with position: sticky; top: 0; zIndex: 1 +``` + +## Smooth Scrolling + +```tsx +const virtualizer = useVirtualizer({ + scrollToFn: (offset, { behavior }, instance) => { + if (behavior === 'smooth') { + // Custom easing animation + instance.scrollElement?.scrollTo({ top: offset, behavior: 'smooth' }) + } else { + instance.scrollElement?.scrollTo({ top: offset }) + } + }, +}) + +// Usage +virtualizer.scrollToIndex(500, { align: 'center', behavior: 'smooth' }) +``` + +## Best Practices + +1. **Overestimate `estimateSize`** - prevents scroll jumps (items shrinking causes issues) +2. **Increase `overscan`** (3-5) to reduce blank flashing during fast scrolling +3. **Use `transform: translateY()`** over `top` for GPU-composited positioning +4. **Add `data-index` attribute** when using `measureElement` for dynamic sizing +5. **Don't set fixed height** on dynamically measured items +6. **Use `getItemKey`** for stable keys when items can reorder +7. **Use `gap` option** instead of margins (margins interfere with measurement) +8. **Use `paddingStart/End`** instead of CSS padding on the container +9. **Use `enabled: false`** to pause when the list is hidden +10. **Memoize callbacks** (`estimateSize`, `getItemKey`, `rangeExtractor`) +11. **Use `will-change: transform`** CSS on items for GPU acceleration + +## Common Pitfalls + +- Setting fixed height on dynamically measured items +- Using CSS margins instead of the `gap` option +- Forgetting `data-index` with `measureElement` +- Not providing `position: relative` on the inner container +- Underestimating `estimateSize` (causes scroll jumps) +- Setting `overscan` too low for fast scrolling (blank items) +- Forgetting to subtract `scrollMargin` from `translateY` in window scrolling +- Not memoizing the `estimateSize` function (causes re-renders) diff --git a/.claude/skills/web-design-guidelines/SKILL.md b/.claude/skills/web-design-guidelines/SKILL.md new file mode 100644 index 0000000..ceae92a --- /dev/null +++ b/.claude/skills/web-design-guidelines/SKILL.md @@ -0,0 +1,39 @@ +--- +name: web-design-guidelines +description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices". +metadata: + author: vercel + version: "1.0.0" + argument-hint: +--- + +# Web Interface Guidelines + +Review files for compliance with Web Interface Guidelines. + +## How It Works + +1. Fetch the latest guidelines from the source URL below +2. Read the specified files (or prompt user for files/pattern) +3. Check against all rules in the fetched guidelines +4. Output findings in the terse `file:line` format + +## Guidelines Source + +Fetch fresh guidelines before each review: + +``` +https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md +``` + +Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions. + +## Usage + +When a user provides a file or pattern argument: +1. Fetch guidelines from the source URL above +2. Read the specified files +3. Apply all rules from the fetched guidelines +4. Output findings using the format specified in the guidelines + +If no files specified, ask the user which files to review. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7dff714 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,96 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +Package manager is **pnpm**. Test runner is **Vitest**. Formatter/linter is **Biome** (not Prettier/ESLint). + +```bash +pnpm dev # Vite dev server on :3000 +pnpm build # Production build (TanStack Start) +pnpm preview # Preview production build +pnpm test # Run vitest once (CI-style) +pnpm check # Biome check (lint + format) +pnpm format # Biome format only +pnpm lint # Biome lint only +``` + +Single test: `pnpm vitest run path/to/file.test.ts` or `pnpm vitest run -t "test name"`. + +### Database + +Postgres is required. Local dev uses the Docker Compose stack; all `db:*` scripts load `.env.local` via `dotenv-cli`. + +```bash +pnpm db:local:start # start local postgres (compose.local.yaml) +pnpm db:generate # prisma generate → writes to prisma/generated/prisma & prisma/generated/prisma-idb +pnpm db:push # push schema without a migration +pnpm db:migrate # create + apply a dev migration +pnpm db:studio # Prisma Studio +pnpm db:seed # run prisma/seed.ts +``` + +Two Prisma clients are generated from a single `schema.prisma`: +- `prisma/generated/prisma` — Postgres server client (used via `src/db.ts` and `prisma/client.ts`). +- `prisma/generated/prisma-idb` — IndexedDB client for the browser (via `@prisma-idb/idb-client-generator`), used offline/client-side in `src/integrations/prisma-idb`. + +Always re-run `pnpm db:generate` after editing `schema.prisma`; both generators run together. + +## Architecture + +### Framework stack + +- **TanStack Start** (SSR + server functions) on top of **TanStack Router** with file-based routing. `vite.config.ts` registers `tanstackStart()` before `@vitejs/plugin-react` — order matters. +- **React 19** with `babel-plugin-react-compiler` enabled. +- **Mantine v9** for UI (core, dates, modals, notifications, carousel, spotlight, tiptap, code-highlight). `next-themes` drives the theme class on ``. +- **Better Auth** for auth, mounted as a catch-all route at `src/routes/api/auth/$.ts`. Prisma adapter, Discord OAuth, email/password with Resend-sent verification + reset emails (React Email templates in `src/emails/auth/`). +- **TanStack Query** integrated with the router via `setupRouterSsrQueryIntegration` in `src/router.tsx`. Shared `QueryClient` is created in `src/integrations/tanstack-query/get-context.ts` and attached to router context. + +### Routing + +- Routes live in `src/routes/`. `routeTree.gen.ts` is **generated** — do not edit (also marked read-only in `.vscode/settings.json` and excluded from Biome). +- Root shell is `src/routes/__root.tsx`: renders ``, global providers (`GameProvider` → `MantineProviderWithTheme` → `ModalsProvider`), and the Mantine `AppShell` (header + navbar + footer). +- Game-scoped URLs live under `src/routes/$gameId/` — the `gameId` param is written into the game store on mount. + +### Game registry pattern + +Everything game-specific hangs off a central registry: + +- `src/features/game/registry/game-registry.ts` wires games keyed by `gameId` to their `GameConfig` (`ITEMS` + `THEME`). +- Each game under `src/games//` exposes a `game-config/index.ts` that `satisfies GameConfig<...>` with game-specific item and category generics (e.g. `Remnant2LocalItem`, `Remnant2ItemCategory`). +- The `GameId` enum is defined in `schema.prisma` and imported from `@/prisma`. Adding a new game means: add the enum value, create `src/games//`, then register it in `game-registry.ts`. +- `getAllRegisteredThemeDefinitions()` expands each game theme into light+dark variants plus a base `default-light`/`default-dark`. Add themes by attaching them to a game's `game-config`, not by editing the registry output. + +### Active-game resolution + +The active game is tracked in a `@tanstack/store` at `src/features/game/store/game-store.ts` with a `source` priority: `subdomain` > `route` > `toggle`/`session` > `default`. Two providers write to it: + +- `GameProvider` (client-only, mounted in `__root.tsx`) — reads `window.location.hostname` (`parseSubdomain`) with a `?_game=` dev override. +- `$gameId/route.tsx` — writes the route param on every navigation. + +A `subdomain`-sourced value deliberately wins over later `route` writes — be careful if you change this precedence. + +### Theme system + +- Mantine theme objects live in `src/features/theme/themes/` and per-game `game-config/theme.ts`. +- `MantineProviderWithTheme` reads the active Mantine theme from `theme-store.ts` and feeds `next-themes` with the full list of registered theme class names (`getAllRegisteredThemeClassNames()`), so `html[data-theme]`/`className` toggling is driven off the registry. +- `SyncAndApplyTheme` syncs `next-themes` ↔ the Mantine store and persists `autoChangeTheme` in `localStorage`. + +### Imports & path aliases + +Three aliases resolve to the same place — use the one already in the file: + +- `#/*` → `./src/*` (declared in `package.json` imports **and** `tsconfig.json` paths). +- `@/*` → `./src/*` (tsconfig only). +- `@/prisma` → `./prisma/client` — this is how you import `prisma` and generated types/enums (e.g. `import type { GameId } from "@/prisma"`). Do **not** import directly from `prisma/generated/prisma`. + +### Env vars + +Validated with zod in `src/config/env.ts`. Server keys (`DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `DISCORD_*`, `IMAGEKIT_*`, `RESEND_KEY`) come from `process.env`; client keys must be `VITE_*` and come from `import.meta.env`. `.env.local.example` is the template. + +## Code style + +- **Tabs** for indentation, double quotes (Biome config). Organize-imports runs on save in VS Code. +- TypeScript is strict with `noUnusedLocals`, `noUnusedParameters`, `verbatimModuleSyntax` — always use `import type` for type-only imports. +- `routeTree.gen.ts` and `styles.css` are excluded from Biome; don't hand-edit them. diff --git a/package.json b/package.json index e5e510d..edce7c1 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@prisma/adapter-pg": "7.4.2", "@prisma/client": "7.4.2", "@react-email/components": "1.0.12", + "@react-email/ui": "6.0.0", "@tanstack/match-sorter-utils": "latest", "@tanstack/react-devtools": "latest", "@tanstack/react-form": "latest", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a003eb..0e31cb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@react-email/components': specifier: 1.0.12 version: 1.0.12(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@react-email/ui': + specifier: 6.0.0 + version: 6.0.0(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tanstack/match-sorter-utils': specifier: latest version: 8.19.4 @@ -106,7 +109,7 @@ importers: version: 3.22.3 better-auth: specifier: 1.5.3 - version: 1.5.3(ee8110846532993bfdee57b1cd80e6c7) + version: 1.5.3(2bdca6cedd22ab1871ad70202a8511c9) dayjs: specifier: 1.11.20 version: 1.11.20 @@ -1078,6 +1081,143 @@ packages: peerDependencies: hono: ^4 + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1182,6 +1322,57 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@next/env@16.2.3': + resolution: {integrity: sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA==} + + '@next/swc-darwin-arm64@16.2.3': + resolution: {integrity: sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.2.3': + resolution: {integrity: sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.2.3': + resolution: {integrity: sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@16.2.3': + resolution: {integrity: sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@16.2.3': + resolution: {integrity: sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@16.2.3': + resolution: {integrity: sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@16.2.3': + resolution: {integrity: sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.2.3': + resolution: {integrity: sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@noble/ciphers@2.2.0': resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==} engines: {node: '>= 20.19.0'} @@ -1480,6 +1671,9 @@ packages: peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc + '@react-email/ui@6.0.0': + resolution: {integrity: sha512-KhlSVZZS6SOh5YlOCTHGsa3jyeyaq4jvI9MIU+mIX3vRQQ/NU/dS1nQJLTbnFyCqz55JvEDfFTX/3hpcYct1lg==} + '@reduxjs/toolkit@2.11.2': resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} peerDependencies: @@ -1762,6 +1956,9 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@tanstack/devtools-client@0.0.6': resolution: {integrity: sha512-f85ZJXJnDIFOoykG/BFIixuAevJovCvJF391LPs6YjBAPhGYC50NWlx1y4iF/UmK5/cCMx+/JqI5SBOz7FanQQ==} engines: {node: '>=18'} @@ -2597,6 +2794,9 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -3404,6 +3604,27 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + next@16.2.3: + resolution: {integrity: sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} @@ -3581,6 +3802,10 @@ packages: peerDependencies: postcss: ^8.2.1 + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.10: resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} @@ -3947,6 +4172,10 @@ packages: set-cookie-parser@3.1.0: resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -4049,6 +4278,19 @@ packages: stubborn-utils@1.0.2: resolution: {integrity: sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==} + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + sugarss@5.0.1: resolution: {integrity: sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw==} engines: {node: '>=18.0'} @@ -5096,6 +5338,103 @@ snapshots: dependencies: hono: 4.11.4 + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.9.2 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5207,6 +5546,32 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@next/env@16.2.3': {} + + '@next/swc-darwin-arm64@16.2.3': + optional: true + + '@next/swc-darwin-x64@16.2.3': + optional: true + + '@next/swc-linux-arm64-gnu@16.2.3': + optional: true + + '@next/swc-linux-arm64-musl@16.2.3': + optional: true + + '@next/swc-linux-x64-gnu@16.2.3': + optional: true + + '@next/swc-linux-x64-musl@16.2.3': + optional: true + + '@next/swc-win32-arm64-msvc@16.2.3': + optional: true + + '@next/swc-win32-x64-msvc@16.2.3': + optional: true + '@noble/ciphers@2.2.0': {} '@noble/hashes@2.2.0': {} @@ -5487,6 +5852,20 @@ snapshots: dependencies: react: 19.2.0 + '@react-email/ui@6.0.0(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + esbuild: 0.27.5 + next: 16.2.3(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + transitivePeerDependencies: + - '@babel/core' + - '@opentelemetry/api' + - '@playwright/test' + - babel-plugin-macros + - babel-plugin-react-compiler + - react + - react-dom + - sass + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.0)(react@19.2.0)(redux@5.0.1))(react@19.2.0)': dependencies: '@standard-schema/spec': 1.1.0 @@ -5679,6 +6058,10 @@ snapshots: '@standard-schema/utils@0.3.0': {} + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + '@tanstack/devtools-client@0.0.6': dependencies: '@tanstack/devtools-event-client': 0.4.3 @@ -6484,7 +6867,7 @@ snapshots: baseline-browser-mapping@2.10.19: {} - better-auth@1.5.3(ee8110846532993bfdee57b1cd80e6c7): + better-auth@1.5.3(2bdca6cedd22ab1871ad70202a8511c9): dependencies: '@better-auth/core': 1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.2.0) '@better-auth/kysely-adapter': 1.5.3(@better-auth/core@1.5.3(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.16) @@ -6505,6 +6888,7 @@ snapshots: '@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.7.2))(typescript@5.7.2) '@tanstack/react-start': 1.167.41(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(vite@8.0.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@22.10.2)(esbuild@0.27.7)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.10))(tsx@4.21.0)) mysql2: 3.15.3 + next: 16.2.3(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) pg: 8.20.0 prisma: 7.4.2(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.7.2) react: 19.2.0 @@ -6640,6 +7024,8 @@ snapshots: cli-spinners@2.9.2: {} + client-only@0.0.1: {} + clsx@2.1.1: {} code-block-writer@13.0.3: {} @@ -7420,6 +7806,31 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + next@16.2.3(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + '@next/env': 16.2.3 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.19 + caniuse-lite: 1.0.30001788 + postcss: 8.4.31 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.0) + optionalDependencies: + '@next/swc-darwin-arm64': 16.2.3 + '@next/swc-darwin-x64': 16.2.3 + '@next/swc-linux-arm64-gnu': 16.2.3 + '@next/swc-linux-arm64-musl': 16.2.3 + '@next/swc-linux-x64-gnu': 16.2.3 + '@next/swc-linux-x64-musl': 16.2.3 + '@next/swc-win32-arm64-msvc': 16.2.3 + '@next/swc-win32-x64-msvc': 16.2.3 + babel-plugin-react-compiler: 1.0.0 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + node-fetch-native@1.6.7: {} node-releases@2.0.37: {} @@ -7619,6 +8030,12 @@ snapshots: dependencies: postcss: 8.5.10 + postcss@8.4.31: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.10: dependencies: nanoid: 3.3.11 @@ -8068,6 +8485,38 @@ snapshots: set-cookie-parser@3.1.0: {} + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -8167,6 +8616,13 @@ snapshots: stubborn-utils@1.0.2: {} + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.0): + dependencies: + client-only: 0.0.1 + react: 19.2.0 + optionalDependencies: + '@babel/core': 7.29.0 + sugarss@5.0.1(postcss@8.5.10): dependencies: postcss: 8.5.10 diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..9bad417 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,55 @@ +{ + "version": 1, + "skills": { + "better-auth-best-practices": { + "source": "better-auth/skills", + "sourceType": "github", + "computedHash": "9ab075b5061be2a5f299c10505667345cc1ec76e8de4120901cfd586643e776f" + }, + "mantine-custom-components": { + "source": "mantinedev/skills", + "sourceType": "github", + "computedHash": "f61c7368715ee81aeb1bf849041e833551ba376d501105ded998553dfaa3e904" + }, + "tanstack-form": { + "source": "tanstack-skills/tanstack-skills", + "sourceType": "github", + "computedHash": "6d00fa22960ef5c45d14906f8f05d91870de658effb8c76bacc69d60a1000098" + }, + "tanstack-query": { + "source": "tanstack-skills/tanstack-skills", + "sourceType": "github", + "computedHash": "bfe2977e92667b84efeb4b12e5c3010807934aab2f7ac2b059241cd3da2a3e6e" + }, + "tanstack-router": { + "source": "tanstack-skills/tanstack-skills", + "sourceType": "github", + "computedHash": "6aba9066ca12bf6bb6eebec9c58f5d4e33b6b06e2b11bf263f5b7a5c18edc389" + }, + "tanstack-start": { + "source": "tanstack-skills/tanstack-skills", + "sourceType": "github", + "computedHash": "31690f8eb3a24feb093bfd14d673f0519078cdcd297f79fc2bbb584b4daa4db1" + }, + "tanstack-store": { + "source": "tanstack-skills/tanstack-skills", + "sourceType": "github", + "computedHash": "a82bb1763290dfdab49c2b506a85da42d4030cfad502ef5c965c7440bfdefa6c" + }, + "tanstack-table": { + "source": "tanstack-skills/tanstack-skills", + "sourceType": "github", + "computedHash": "5527e42e541ac26fc1157e49d58c399b95e44eb276ef3caa7576ab138558d593" + }, + "tanstack-virtual": { + "source": "tanstack-skills/tanstack-skills", + "sourceType": "github", + "computedHash": "3696d2db04a03373dfdab1f8d5c2249989c6d8c299bb02c4491b25d2b446e51c" + }, + "web-design-guidelines": { + "source": "vercel-labs/agent-skills", + "sourceType": "github", + "computedHash": "f3bc47f890f42a44db1007ab390709ec368e4b8c089baee6b0007182236ac474" + } + } +}