diff --git a/.agents/skills/vercel-react-native-skills/AGENTS.md b/.agents/skills/vercel-react-native-skills/AGENTS.md
deleted file mode 100644
index d263eb9..0000000
--- a/.agents/skills/vercel-react-native-skills/AGENTS.md
+++ /dev/null
@@ -1,2897 +0,0 @@
-# React Native Skills
-
-**Version 1.0.0**
-Engineering
-January 2026
-
-> **Note:**
-> This document is mainly for agents and LLMs to follow when maintaining,
-> generating, or refactoring React Native codebases. Humans
-> may also find it useful, but guidance here is optimized for automation
-> and consistency by AI-assisted workflows.
-
----
-
-## Abstract
-
-Comprehensive performance optimization guide for React Native applications, designed for AI agents and LLMs. Contains 35+ rules across 13 categories, prioritized by impact from critical (core rendering, list performance) to incremental (fonts, imports). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.
-
----
-
-## Table of Contents
-
-1. [Core Rendering](#1-core-rendering) — **CRITICAL**
- - 1.1 [Never Use && with Potentially Falsy Values](#11-never-use--with-potentially-falsy-values)
- - 1.2 [Wrap Strings in Text Components](#12-wrap-strings-in-text-components)
-2. [List Performance](#2-list-performance) — **HIGH**
- - 2.1 [Avoid Inline Objects in renderItem](#21-avoid-inline-objects-in-renderitem)
- - 2.2 [Hoist callbacks to the root of lists](#22-hoist-callbacks-to-the-root-of-lists)
- - 2.3 [Keep List Items Lightweight](#23-keep-list-items-lightweight)
- - 2.4 [Optimize List Performance with Stable Object References](#24-optimize-list-performance-with-stable-object-references)
- - 2.5 [Pass Primitives to List Items for Memoization](#25-pass-primitives-to-list-items-for-memoization)
- - 2.6 [Use a List Virtualizer for Any List](#26-use-a-list-virtualizer-for-any-list)
- - 2.7 [Use Compressed Images in Lists](#27-use-compressed-images-in-lists)
- - 2.8 [Use Item Types for Heterogeneous Lists](#28-use-item-types-for-heterogeneous-lists)
-3. [Animation](#3-animation) — **HIGH**
- - 3.1 [Animate Transform and Opacity Instead of Layout Properties](#31-animate-transform-and-opacity-instead-of-layout-properties)
- - 3.2 [Prefer useDerivedValue Over useAnimatedReaction](#32-prefer-usederivedvalue-over-useanimatedreaction)
- - 3.3 [Use GestureDetector for Animated Press States](#33-use-gesturedetector-for-animated-press-states)
-4. [Scroll Performance](#4-scroll-performance) — **HIGH**
- - 4.1 [Never Track Scroll Position in useState](#41-never-track-scroll-position-in-usestate)
-5. [Navigation](#5-navigation) — **HIGH**
- - 5.1 [Use Native Navigators for Navigation](#51-use-native-navigators-for-navigation)
-6. [React State](#6-react-state) — **MEDIUM**
- - 6.1 [Minimize State Variables and Derive Values](#61-minimize-state-variables-and-derive-values)
- - 6.2 [Use fallback state instead of initialState](#62-use-fallback-state-instead-of-initialstate)
- - 6.3 [useState Dispatch updaters for State That Depends on Current Value](#63-usestate-dispatch-updaters-for-state-that-depends-on-current-value)
-7. [State Architecture](#7-state-architecture) — **MEDIUM**
- - 7.1 [State Must Represent Ground Truth](#71-state-must-represent-ground-truth)
-8. [React Compiler](#8-react-compiler) — **MEDIUM**
- - 8.1 [Destructure Functions Early in Render (React Compiler)](#81-destructure-functions-early-in-render-react-compiler)
- - 8.2 [Use .get() and .set() for Reanimated Shared Values (not .value)](#82-use-get-and-set-for-reanimated-shared-values-not-value)
-9. [User Interface](#9-user-interface) — **MEDIUM**
- - 9.1 [Measuring View Dimensions](#91-measuring-view-dimensions)
- - 9.2 [Modern React Native Styling Patterns](#92-modern-react-native-styling-patterns)
- - 9.3 [Use contentInset for Dynamic ScrollView Spacing](#93-use-contentinset-for-dynamic-scrollview-spacing)
- - 9.4 [Use contentInsetAdjustmentBehavior for Safe Areas](#94-use-contentinsetadjustmentbehavior-for-safe-areas)
- - 9.5 [Use expo-image for Optimized Images](#95-use-expo-image-for-optimized-images)
- - 9.6 [Use Galeria for Image Galleries and Lightbox](#96-use-galeria-for-image-galleries-and-lightbox)
- - 9.7 [Use Native Menus for Dropdowns and Context Menus](#97-use-native-menus-for-dropdowns-and-context-menus)
- - 9.8 [Use Native Modals Over JS-Based Bottom Sheets](#98-use-native-modals-over-js-based-bottom-sheets)
- - 9.9 [Use Pressable Instead of Touchable Components](#99-use-pressable-instead-of-touchable-components)
-10. [Design System](#10-design-system) — **MEDIUM**
- - 10.1 [Use Compound Components Over Polymorphic Children](#101-use-compound-components-over-polymorphic-children)
-11. [Monorepo](#11-monorepo) — **LOW**
- - 11.1 [Install Native Dependencies in App Directory](#111-install-native-dependencies-in-app-directory)
- - 11.2 [Use Single Dependency Versions Across Monorepo](#112-use-single-dependency-versions-across-monorepo)
-12. [Third-Party Dependencies](#12-third-party-dependencies) — **LOW**
- - 12.1 [Import from Design System Folder](#121-import-from-design-system-folder)
-13. [JavaScript](#13-javascript) — **LOW**
- - 13.1 [Hoist Intl Formatter Creation](#131-hoist-intl-formatter-creation)
-14. [Fonts](#14-fonts) — **LOW**
- - 14.1 [Load fonts natively at build time](#141-load-fonts-natively-at-build-time)
-
----
-
-## 1. Core Rendering
-
-**Impact: CRITICAL**
-
-Fundamental React Native rendering rules. Violations cause
-runtime crashes or broken UI.
-
-### 1.1 Never Use && with Potentially Falsy Values
-
-**Impact: CRITICAL (prevents production crash)**
-
-Never use `{value && }` when `value` could be an empty string or
-
-`0`. These are falsy but JSX-renderable—React Native will try to render them as
-
-text outside a `` component, causing a hard crash in production.
-
-**Incorrect: crashes if count is 0 or name is ""**
-
-```tsx
-function Profile({ name, count }: { name: string; count: number }) {
- return (
-
- {name && {name}}
- {count && {count} items}
-
- )
-}
-// If name="" or count=0, renders the falsy value → crash
-```
-
-**Correct: ternary with null**
-
-```tsx
-function Profile({ name, count }: { name: string; count: number }) {
- return (
-
- {name ? {name} : null}
- {count ? {count} items : null}
-
- )
-}
-```
-
-**Correct: explicit boolean coercion**
-
-```tsx
-function Profile({ name, count }: { name: string; count: number }) {
- return (
-
- {!!name && {name}}
- {!!count && {count} items}
-
- )
-}
-```
-
-**Best: early return**
-
-```tsx
-function Profile({ name, count }: { name: string; count: number }) {
- if (!name) return null
-
- return (
-
- {name}
- {count > 0 ? {count} items : null}
-
- )
-}
-```
-
-Early returns are clearest. When using conditionals inline, prefer ternary or
-
-explicit boolean checks.
-
-**Lint rule:** Enable `react/jsx-no-leaked-render` from
-
-[eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-no-leaked-render.md)
-
-to catch this automatically.
-
-### 1.2 Wrap Strings in Text Components
-
-**Impact: CRITICAL (prevents runtime crash)**
-
-Strings must be rendered inside ``. React Native crashes if a string is a
-
-direct child of ``.
-
-**Incorrect: crashes**
-
-```tsx
-import { View } from 'react-native'
-
-function Greeting({ name }: { name: string }) {
- return Hello, {name}!
-}
-// Error: Text strings must be rendered within a component.
-```
-
-**Correct:**
-
-```tsx
-import { View, Text } from 'react-native'
-
-function Greeting({ name }: { name: string }) {
- return (
-
- Hello, {name}!
-
- )
-}
-```
-
----
-
-## 2. List Performance
-
-**Impact: HIGH**
-
-Optimizing virtualized lists (FlatList, LegendList, FlashList)
-for smooth scrolling and fast updates.
-
-### 2.1 Avoid Inline Objects in renderItem
-
-**Impact: HIGH (prevents unnecessary re-renders of memoized list items)**
-
-Don't create new objects inside `renderItem` to pass as props. Inline objects
-
-create new references on every render, breaking memoization. Pass primitive
-
-values directly from `item` instead.
-
-**Incorrect: inline object breaks memoization**
-
-```tsx
-function UserList({ users }: { users: User[] }) {
- return (
- (
-
- )}
- />
- )
-}
-```
-
-**Incorrect: inline style object**
-
-```tsx
-renderItem={({ item }) => (
-
-)}
-```
-
-**Correct: pass item directly or primitives**
-
-```tsx
-function UserList({ users }: { users: User[] }) {
- return (
- (
- // Good: pass the item directly
-
- )}
- />
- )
-}
-```
-
-**Correct: pass primitives, derive inside child**
-
-```tsx
-renderItem={({ item }) => (
-
-)}
-
-const UserRow = memo(function UserRow({ id, name, isActive }: Props) {
- // Good: derive style inside memoized component
- const backgroundColor = isActive ? 'green' : 'gray'
- return {/* ... */}
-})
-```
-
-**Correct: hoist static styles in module scope**
-
-```tsx
-const activeStyle = { backgroundColor: 'green' }
-const inactiveStyle = { backgroundColor: 'gray' }
-
-renderItem={({ item }) => (
-
-)}
-```
-
-Passing primitives or stable references allows `memo()` to skip re-renders when
-
-the actual values haven't changed.
-
-**Note:** If you have the React Compiler enabled, it handles memoization
-
-automatically and these manual optimizations become less critical.
-
-### 2.2 Hoist callbacks to the root of lists
-
-**Impact: MEDIUM (Fewer re-renders and faster lists)**
-
-When passing callback functions to list items, create a single instance of the
-
-callback at the root of the list. Items should then call it with a unique
-
-identifier.
-
-**Incorrect: creates a new callback on each render**
-
-```typescript
-return (
- {
- // bad: creates a new callback on each render
- const onPress = () => handlePress(item.id)
- return
- }}
- />
-)
-```
-
-**Correct: a single function instance passed to each item**
-
-```typescript
-const onPress = useCallback(() => handlePress(item.id), [handlePress, item.id])
-
-return (
- (
-
- )}
- />
-)
-```
-
-Reference: [https://example.com](https://example.com)
-
-### 2.3 Keep List Items Lightweight
-
-**Impact: HIGH (reduces render time for visible items during scroll)**
-
-List items should be as inexpensive as possible to render. Minimize hooks, avoid
-
-queries, and limit React Context access. Virtualized lists render many items
-
-during scroll—expensive items cause jank.
-
-**Incorrect: heavy list item**
-
-```tsx
-function ProductRow({ id }: { id: string }) {
- // Bad: query inside list item
- const { data: product } = useQuery(['product', id], () => fetchProduct(id))
- // Bad: multiple context accesses
- const theme = useContext(ThemeContext)
- const user = useContext(UserContext)
- const cart = useContext(CartContext)
- // Bad: expensive computation
- const recommendations = useMemo(
- () => computeRecommendations(product),
- [product]
- )
-
- return {/* ... */}
-}
-```
-
-**Correct: lightweight list item**
-
-```tsx
-function ProductRow({ name, price, imageUrl }: Props) {
- // Good: receives only primitives, minimal hooks
- return (
-
-
- {name}
- {price}
-
- )
-}
-```
-
-**Move data fetching to parent:**
-
-```tsx
-// Parent fetches all data once
-function ProductList() {
- const { data: products } = useQuery(['products'], fetchProducts)
-
- return (
- (
-
- )}
- />
- )
-}
-```
-
-**For shared values, use Zustand selectors instead of Context:**
-
-```tsx
-// Incorrect: Context causes re-render when any cart value changes
-function ProductRow({ id, name }: Props) {
- const { items } = useContext(CartContext)
- const inCart = items.includes(id)
- // ...
-}
-
-// Correct: Zustand selector only re-renders when this specific value changes
-function ProductRow({ id, name }: Props) {
- // use Set.has (created once at the root) instead of Array.includes()
- const inCart = useCartStore((s) => s.items.has(id))
- // ...
-}
-```
-
-**Guidelines for list items:**
-
-- No queries or data fetching
-
-- No expensive computations (move to parent or memoize at parent level)
-
-- Prefer Zustand selectors over React Context
-
-- Minimize useState/useEffect hooks
-
-- Pass pre-computed values as props
-
-The goal: list items should be simple rendering functions that take props and
-
-return JSX.
-
-### 2.4 Optimize List Performance with Stable Object References
-
-**Impact: CRITICAL (virtualization relies on reference stability)**
-
-Don't map or filter data before passing to virtualized lists. Virtualization
-
-relies on object reference stability to know what changed—new references cause
-
-full re-renders of all visible items. Attempt to prevent frequent renders at the
-
-list-parent level.
-
-Where needed, use context selectors within list items.
-
-**Incorrect: creates new object references on every keystroke**
-
-```tsx
-function DomainSearch() {
- const { keyword, setKeyword } = useKeywordZustandState()
- const { data: tlds } = useTlds()
-
- // Bad: creates new objects on every render, reparenting the entire list on every keystroke
- const domains = tlds.map((tld) => ({
- domain: `${keyword}.${tld.name}`,
- tld: tld.name,
- price: tld.price,
- }))
-
- return (
- <>
-
- }
- />
- >
- )
-}
-```
-
-**Correct: stable references, transform inside items**
-
-```tsx
-const renderItem = ({ item }) =>
-
-function DomainSearch() {
- const { data: tlds } = useTlds()
-
- return (
-
- )
-}
-
-function DomainItem({ tld }: { tld: Tld }) {
- // good: transform within items, and don't pass the dynamic data as a prop
- // good: use a selector function from zustand to receive a stable string back
- const domain = useKeywordZustandState((s) => s.keyword + '.' + tld.name)
- return {domain}
-}
-```
-
-**Updating parent array reference:**
-
-```tsx
-// good: creates a new array instance without mutating the inner objects
-// good: parent array reference is unaffected by typing and updating "keyword"
-const sortedTlds = tlds.toSorted((a, b) => a.name.localeCompare(b.name))
-
-return
-```
-
-Creating a new array instance can be okay, as long as its inner object
-
-references are stable. For instance, if you sort a list of objects:
-
-Even though this creates a new array instance `sortedTlds`, the inner object
-
-references are stable.
-
-**With zustand for dynamic data: avoids parent re-renders**
-
-```tsx
-function DomainItemFavoriteButton({ tld }: { tld: Tld }) {
- const isFavorited = useFavoritesStore((s) => s.favorites.has(tld.id))
- return
-}
-```
-
-Virtualization can now skip items that haven't changed when typing. Only visible
-
-items (~20) re-render on keystroke, rather than the parent.
-
-**Deriving state within list items based on parent data (avoids parent
-
-re-renders):**
-
-For components where the data is conditional based on the parent state, this
-
-pattern is even more important. For example, if you are checking if an item is
-
-favorited, toggling favorites only re-renders one component if the item itself
-
-is in charge of accessing the state rather than the parent:
-
-Note: if you're using the React Compiler, you can read React Context values
-
-directly within list items. Although this is slightly slower than using a
-
-Zustand selector in most cases, the effect may be negligible.
-
-### 2.5 Pass Primitives to List Items for Memoization
-
-**Impact: HIGH (enables effective memo() comparison)**
-
-When possible, pass only primitive values (strings, numbers, booleans) as props
-
-to list item components. Primitives enable shallow comparison in `memo()` to
-
-work correctly, skipping re-renders when values haven't changed.
-
-**Incorrect: object prop requires deep comparison**
-
-```tsx
-type User = { id: string; name: string; email: string; avatar: string }
-
-const UserRow = memo(function UserRow({ user }: { user: User }) {
- // memo() compares user by reference, not value
- // If parent creates new user object, this re-renders even if data is same
- return {user.name}
-})
-
-renderItem={({ item }) => }
-```
-
-This can still be optimized, but it is harder to memoize properly.
-
-**Correct: primitive props enable shallow comparison**
-
-```tsx
-const UserRow = memo(function UserRow({
- id,
- name,
- email,
-}: {
- id: string
- name: string
- email: string
-}) {
- // memo() compares each primitive directly
- // Re-renders only if id, name, or email actually changed
- return {name}
-})
-
-renderItem={({ item }) => (
-
-)}
-```
-
-**Pass only what you need:**
-
-```tsx
-// Incorrect: passing entire item when you only need name
-
-
-// Correct: pass only the fields the component uses
-
-```
-
-**For callbacks, hoist or use item ID:**
-
-```tsx
-// Incorrect: inline function creates new reference
- handlePress(item.id)} />
-
-// Correct: pass ID, handle in child
-
-
-const UserRow = memo(function UserRow({ id, name }: Props) {
- const handlePress = useCallback(() => {
- // use id here
- }, [id])
- return {name}
-})
-```
-
-Primitive props make memoization predictable and effective.
-
-**Note:** If you have the React Compiler enabled, you do not need to use
-
-`memo()` or `useCallback()`, but the object references still apply.
-
-### 2.6 Use a List Virtualizer for Any List
-
-**Impact: HIGH (reduced memory, faster mounts)**
-
-Use a list virtualizer like LegendList or FlashList instead of ScrollView with
-
-mapped children—even for short lists. Virtualizers only render visible items,
-
-reducing memory usage and mount time. ScrollView renders all children upfront,
-
-which gets expensive quickly.
-
-**Incorrect: ScrollView renders all items at once**
-
-```tsx
-function Feed({ items }: { items: Item[] }) {
- return (
-
- {items.map((item) => (
-
- ))}
-
- )
-}
-// 50 items = 50 components mounted, even if only 10 visible
-```
-
-**Correct: virtualizer renders only visible items**
-
-```tsx
-import { LegendList } from '@legendapp/list'
-
-function Feed({ items }: { items: Item[] }) {
- return (
- }
- keyExtractor={(item) => item.id}
- estimatedItemSize={80}
- />
- )
-}
-// Only ~10-15 visible items mounted at a time
-```
-
-**Alternative: FlashList**
-
-```tsx
-import { FlashList } from '@shopify/flash-list'
-
-function Feed({ items }: { items: Item[] }) {
- return (
- }
- keyExtractor={(item) => item.id}
- />
- )
-}
-```
-
-Benefits apply to any screen with scrollable content—profiles, settings, feeds,
-
-search results. Default to virtualization.
-
-### 2.7 Use Compressed Images in Lists
-
-**Impact: HIGH (faster load times, less memory)**
-
-Always load compressed, appropriately-sized images in lists. Full-resolution
-
-images consume excessive memory and cause scroll jank. Request thumbnails from
-
-your server or use an image CDN with resize parameters.
-
-**Incorrect: full-resolution images**
-
-```tsx
-function ProductItem({ product }: { product: Product }) {
- return (
-
- {/* 4000x3000 image loaded for a 100x100 thumbnail */}
-
- {product.name}
-
- )
-}
-```
-
-**Correct: request appropriately-sized image**
-
-```tsx
-function ProductItem({ product }: { product: Product }) {
- // Request a 200x200 image (2x for retina)
- const thumbnailUrl = `${product.imageUrl}?w=200&h=200&fit=cover`
-
- return (
-
-
- {product.name}
-
- )
-}
-```
-
-Use an optimized image component with built-in caching and placeholder support,
-
-such as `expo-image` or `SolitoImage` (which uses `expo-image` under the hood).
-
-Request images at 2x the display size for retina screens.
-
-### 2.8 Use Item Types for Heterogeneous Lists
-
-**Impact: HIGH (efficient recycling, less layout thrashing)**
-
-When a list has different item layouts (messages, images, headers, etc.), use a
-
-`type` field on each item and provide `getItemType` to the list. This puts items
-
-into separate recycling pools so a message component never gets recycled into an
-
-image component.
-
-[LegendList getItemType](https://legendapp.com/open-source/list/api/props/#getitemtype-v2)
-
-**Incorrect: single component with conditionals**
-
-```tsx
-type Item = { id: string; text?: string; imageUrl?: string; isHeader?: boolean }
-
-function ListItem({ item }: { item: Item }) {
- if (item.isHeader) {
- return
- }
- if (item.imageUrl) {
- return
- }
- return
-}
-
-function Feed({ items }: { items: Item[] }) {
- return (
- }
- recycleItems
- />
- )
-}
-```
-
-**Correct: typed items with separate components**
-
-```tsx
-type HeaderItem = { id: string; type: 'header'; title: string }
-type MessageItem = { id: string; type: 'message'; text: string }
-type ImageItem = { id: string; type: 'image'; url: string }
-type FeedItem = HeaderItem | MessageItem | ImageItem
-
-function Feed({ items }: { items: FeedItem[] }) {
- return (
- item.id}
- getItemType={(item) => item.type}
- renderItem={({ item }) => {
- switch (item.type) {
- case 'header':
- return
- case 'message':
- return
- case 'image':
- return
- }
- }}
- recycleItems
- />
- )
-}
-```
-
-**Why this matters:**
-
-```tsx
- item.id}
- getItemType={(item) => item.type}
- getEstimatedItemSize={(index, item, itemType) => {
- switch (itemType) {
- case 'header':
- return 48
- case 'message':
- return 72
- case 'image':
- return 300
- default:
- return 72
- }
- }}
- renderItem={({ item }) => {
- /* ... */
- }}
- recycleItems
-/>
-```
-
-- **Recycling efficiency**: Items with the same type share a recycling pool
-
-- **No layout thrashing**: A header never recycles into an image cell
-
-- **Type safety**: TypeScript can narrow the item type in each branch
-
-- **Better size estimation**: Use `getEstimatedItemSize` with `itemType` for
-
- accurate estimates per type
-
----
-
-## 3. Animation
-
-**Impact: HIGH**
-
-GPU-accelerated animations, Reanimated patterns, and avoiding
-render thrashing during gestures.
-
-### 3.1 Animate Transform and Opacity Instead of Layout Properties
-
-**Impact: HIGH (GPU-accelerated animations, no layout recalculation)**
-
-Avoid animating `width`, `height`, `top`, `left`, `margin`, or `padding`. These trigger layout recalculation on every frame. Instead, use `transform` (scale, translate) and `opacity` which run on the GPU without triggering layout.
-
-**Incorrect: animates height, triggers layout every frame**
-
-```tsx
-import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'
-
-function CollapsiblePanel({ expanded }: { expanded: boolean }) {
- const animatedStyle = useAnimatedStyle(() => ({
- height: withTiming(expanded ? 200 : 0), // triggers layout on every frame
- overflow: 'hidden',
- }))
-
- return {children}
-}
-```
-
-**Correct: animates scaleY, GPU-accelerated**
-
-```tsx
-import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'
-
-function CollapsiblePanel({ expanded }: { expanded: boolean }) {
- const animatedStyle = useAnimatedStyle(() => ({
- transform: [
- { scaleY: withTiming(expanded ? 1 : 0) },
- ],
- opacity: withTiming(expanded ? 1 : 0),
- }))
-
- return (
-
- {children}
-
- )
-}
-```
-
-**Correct: animates translateY for slide animations**
-
-```tsx
-import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'
-
-function SlideIn({ visible }: { visible: boolean }) {
- const animatedStyle = useAnimatedStyle(() => ({
- transform: [
- { translateY: withTiming(visible ? 0 : 100) },
- ],
- opacity: withTiming(visible ? 1 : 0),
- }))
-
- return {children}
-}
-```
-
-GPU-accelerated properties: `transform` (translate, scale, rotate), `opacity`. Everything else triggers layout.
-
-### 3.2 Prefer useDerivedValue Over useAnimatedReaction
-
-**Impact: MEDIUM (cleaner code, automatic dependency tracking)**
-
-When deriving a shared value from another, use `useDerivedValue` instead of
-
-`useAnimatedReaction`. Derived values are declarative, automatically track
-
-dependencies, and return a value you can use directly. Animated reactions are
-
-for side effects, not derivations.
-
-[Reanimated useDerivedValue](https://docs.swmansion.com/react-native-reanimated/docs/core/useDerivedValue)
-
-**Incorrect: useAnimatedReaction for derivation**
-
-```tsx
-import { useSharedValue, useAnimatedReaction } from 'react-native-reanimated'
-
-function MyComponent() {
- const progress = useSharedValue(0)
- const opacity = useSharedValue(1)
-
- useAnimatedReaction(
- () => progress.value,
- (current) => {
- opacity.value = 1 - current
- }
- )
-
- // ...
-}
-```
-
-**Correct: useDerivedValue**
-
-```tsx
-import { useSharedValue, useDerivedValue } from 'react-native-reanimated'
-
-function MyComponent() {
- const progress = useSharedValue(0)
-
- const opacity = useDerivedValue(() => 1 - progress.get())
-
- // ...
-}
-```
-
-Use `useAnimatedReaction` only for side effects that don't produce a value
-
-(e.g., triggering haptics, logging, calling `runOnJS`).
-
-### 3.3 Use GestureDetector for Animated Press States
-
-**Impact: MEDIUM (UI thread animations, smoother press feedback)**
-
-For animated press states (scale, opacity on press), use `GestureDetector` with
-
-`Gesture.Tap()` and shared values instead of Pressable's
-
-`onPressIn`/`onPressOut`. Gesture callbacks run on the UI thread as worklets—no
-
-JS thread round-trip for press animations.
-
-[Gesture Handler Tap Gesture](https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/tap-gesture)
-
-**Incorrect: Pressable with JS thread callbacks**
-
-```tsx
-import { Pressable } from 'react-native'
-import Animated, {
- useSharedValue,
- useAnimatedStyle,
- withTiming,
-} from 'react-native-reanimated'
-
-function AnimatedButton({ onPress }: { onPress: () => void }) {
- const scale = useSharedValue(1)
-
- const animatedStyle = useAnimatedStyle(() => ({
- transform: [{ scale: scale.value }],
- }))
-
- return (
- (scale.value = withTiming(0.95))}
- onPressOut={() => (scale.value = withTiming(1))}
- >
-
- Press me
-
-
- )
-}
-```
-
-**Correct: GestureDetector with UI thread worklets**
-
-```tsx
-import { Gesture, GestureDetector } from 'react-native-gesture-handler'
-import Animated, {
- useSharedValue,
- useAnimatedStyle,
- withTiming,
- interpolate,
- runOnJS,
-} from 'react-native-reanimated'
-
-function AnimatedButton({ onPress }: { onPress: () => void }) {
- // Store the press STATE (0 = not pressed, 1 = pressed)
- const pressed = useSharedValue(0)
-
- const tap = Gesture.Tap()
- .onBegin(() => {
- pressed.set(withTiming(1))
- })
- .onFinalize(() => {
- pressed.set(withTiming(0))
- })
- .onEnd(() => {
- runOnJS(onPress)()
- })
-
- // Derive visual values from the state
- const animatedStyle = useAnimatedStyle(() => ({
- transform: [
- { scale: interpolate(withTiming(pressed.get()), [0, 1], [1, 0.95]) },
- ],
- }))
-
- return (
-
-
- Press me
-
-
- )
-}
-```
-
-Store the press **state** (0 or 1), then derive the scale via `interpolate`.
-
-This keeps the shared value as ground truth. Use `runOnJS` to call JS functions
-
-from worklets. Use `.set()` and `.get()` for React Compiler compatibility.
-
----
-
-## 4. Scroll Performance
-
-**Impact: HIGH**
-
-Tracking scroll position without causing render thrashing.
-
-### 4.1 Never Track Scroll Position in useState
-
-**Impact: HIGH (prevents render thrashing during scroll)**
-
-Never store scroll position in `useState`. Scroll events fire rapidly—state
-
-updates cause render thrashing and dropped frames. Use a Reanimated shared value
-
-for animations or a ref for non-reactive tracking.
-
-**Incorrect: useState causes jank**
-
-```tsx
-import { useState } from 'react'
-import {
- ScrollView,
- NativeSyntheticEvent,
- NativeScrollEvent,
-} from 'react-native'
-
-function Feed() {
- const [scrollY, setScrollY] = useState(0)
-
- const onScroll = (e: NativeSyntheticEvent) => {
- setScrollY(e.nativeEvent.contentOffset.y) // re-renders on every frame
- }
-
- return
-}
-```
-
-**Correct: Reanimated for animations**
-
-```tsx
-import Animated, {
- useSharedValue,
- useAnimatedScrollHandler,
-} from 'react-native-reanimated'
-
-function Feed() {
- const scrollY = useSharedValue(0)
-
- const onScroll = useAnimatedScrollHandler({
- onScroll: (e) => {
- scrollY.value = e.contentOffset.y // runs on UI thread, no re-render
- },
- })
-
- return (
-
- )
-}
-```
-
-**Correct: ref for non-reactive tracking**
-
-```tsx
-import { useRef } from 'react'
-import {
- ScrollView,
- NativeSyntheticEvent,
- NativeScrollEvent,
-} from 'react-native'
-
-function Feed() {
- const scrollY = useRef(0)
-
- const onScroll = (e: NativeSyntheticEvent) => {
- scrollY.current = e.nativeEvent.contentOffset.y // no re-render
- }
-
- return
-}
-```
-
----
-
-## 5. Navigation
-
-**Impact: HIGH**
-
-Using native navigators for stack and tab navigation instead of
-JS-based alternatives.
-
-### 5.1 Use Native Navigators for Navigation
-
-**Impact: HIGH (native performance, platform-appropriate UI)**
-
-Always use native navigators instead of JS-based ones. Native navigators use
-
-platform APIs (UINavigationController on iOS, Fragment on Android) for better
-
-performance and native behavior.
-
-**For stacks:** Use `@react-navigation/native-stack` or expo-router's default
-
-stack (which uses native-stack). Avoid `@react-navigation/stack`.
-
-**For tabs:** Use `react-native-bottom-tabs` (native) or expo-router's native
-
-tabs. Avoid `@react-navigation/bottom-tabs` when native feel matters.
-
-- [React Navigation Native Stack](https://reactnavigation.org/docs/native-stack-navigator)
-
-- [React Native Bottom Tabs with React Navigation](https://oss.callstack.com/react-native-bottom-tabs/docs/guides/usage-with-react-navigation)
-
-- [React Native Bottom Tabs with Expo Router](https://oss.callstack.com/react-native-bottom-tabs/docs/guides/usage-with-expo-router)
-
-- [Expo Router Native Tabs](https://docs.expo.dev/router/advanced/native-tabs)
-
-**Incorrect: JS stack navigator**
-
-```tsx
-import { createStackNavigator } from '@react-navigation/stack'
-
-const Stack = createStackNavigator()
-
-function App() {
- return (
-
-
-
-
- )
-}
-```
-
-**Correct: native stack with react-navigation**
-
-```tsx
-import { createNativeStackNavigator } from '@react-navigation/native-stack'
-
-const Stack = createNativeStackNavigator()
-
-function App() {
- return (
-
-
-
-
- )
-}
-```
-
-**Correct: expo-router uses native stack by default**
-
-```tsx
-// app/_layout.tsx
-import { Stack } from 'expo-router'
-
-export default function Layout() {
- return
-}
-```
-
-**Incorrect: JS bottom tabs**
-
-```tsx
-import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'
-
-const Tab = createBottomTabNavigator()
-
-function App() {
- return (
-
-
-
-
- )
-}
-```
-
-**Correct: native bottom tabs with react-navigation**
-
-```tsx
-import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation'
-
-const Tab = createNativeBottomTabNavigator()
-
-function App() {
- return (
-
- ({ sfSymbol: 'house' }),
- }}
- />
- ({ sfSymbol: 'gear' }),
- }}
- />
-
- )
-}
-```
-
-**Correct: expo-router native tabs**
-
-```tsx
-// app/(tabs)/_layout.tsx
-import { NativeTabs } from 'expo-router/unstable-native-tabs'
-
-export default function TabLayout() {
- return (
-
-
- Home
-
-
-
- Settings
-
-
-
- )
-}
-```
-
-On iOS, native tabs automatically enable `contentInsetAdjustmentBehavior` on the
-
-first `ScrollView` at the root of each tab screen, so content scrolls correctly
-
-behind the translucent tab bar. If you need to disable this, use
-
-`disableAutomaticContentInsets` on the trigger.
-
-**Incorrect: custom header component**
-
-```tsx
- ,
- }}
-/>
-```
-
-**Correct: native header options**
-
-```tsx
-
-```
-
-Native headers support iOS large titles, search bars, blur effects, and proper
-
-safe area handling automatically.
-
-- **Performance**: Native transitions and gestures run on the UI thread
-
-- **Platform behavior**: Automatic iOS large titles, Android material design
-
-- **System integration**: Scroll-to-top on tab tap, PiP avoidance, proper safe
-
- areas
-
-- **Accessibility**: Platform accessibility features work automatically
-
----
-
-## 6. React State
-
-**Impact: MEDIUM**
-
-Patterns for managing React state to avoid stale closures and
-unnecessary re-renders.
-
-### 6.1 Minimize State Variables and Derive Values
-
-**Impact: MEDIUM (fewer re-renders, less state drift)**
-
-Use the fewest state variables possible. If a value can be computed from existing state or props, derive it during render instead of storing it in state. Redundant state causes unnecessary re-renders and can drift out of sync.
-
-**Incorrect: redundant state**
-
-```tsx
-function Cart({ items }: { items: Item[] }) {
- const [total, setTotal] = useState(0)
- const [itemCount, setItemCount] = useState(0)
-
- useEffect(() => {
- setTotal(items.reduce((sum, item) => sum + item.price, 0))
- setItemCount(items.length)
- }, [items])
-
- return (
-
- {itemCount} items
- Total: ${total}
-
- )
-}
-```
-
-**Correct: derived values**
-
-```tsx
-function Cart({ items }: { items: Item[] }) {
- const total = items.reduce((sum, item) => sum + item.price, 0)
- const itemCount = items.length
-
- return (
-
- {itemCount} items
- Total: ${total}
-
- )
-}
-```
-
-**Another example:**
-
-```tsx
-// Incorrect: storing both firstName, lastName, AND fullName
-const [firstName, setFirstName] = useState('')
-const [lastName, setLastName] = useState('')
-const [fullName, setFullName] = useState('')
-
-// Correct: derive fullName
-const [firstName, setFirstName] = useState('')
-const [lastName, setLastName] = useState('')
-const fullName = `${firstName} ${lastName}`
-```
-
-State should be the minimal source of truth. Everything else is derived.
-
-Reference: [https://react.dev/learn/choosing-the-state-structure](https://react.dev/learn/choosing-the-state-structure)
-
-### 6.2 Use fallback state instead of initialState
-
-**Impact: MEDIUM (reactive fallbacks without syncing)**
-
-Use `undefined` as initial state and nullish coalescing (`??`) to fall back to
-
-parent or server values. State represents user intent only—`undefined` means
-
-"user hasn't chosen yet." This enables reactive fallbacks that update when the
-
-source changes, not just on initial render.
-
-**Incorrect: syncs state, loses reactivity**
-
-```tsx
-type Props = { fallbackEnabled: boolean }
-
-function Toggle({ fallbackEnabled }: Props) {
- const [enabled, setEnabled] = useState(defaultEnabled)
- // If fallbackEnabled changes, state is stale
- // State mixes user intent with default value
-
- return
-}
-```
-
-**Correct: state is user intent, reactive fallback**
-
-```tsx
-type Props = { fallbackEnabled: boolean }
-
-function Toggle({ fallbackEnabled }: Props) {
- const [_enabled, setEnabled] = useState(undefined)
- const enabled = _enabled ?? defaultEnabled
- // undefined = user hasn't touched it, falls back to prop
- // If defaultEnabled changes, component reflects it
- // Once user interacts, their choice persists
-
- return
-}
-```
-
-**With server data:**
-
-```tsx
-function ProfileForm({ data }: { data: User }) {
- const [_theme, setTheme] = useState(undefined)
- const theme = _theme ?? data.theme
- // Shows server value until user overrides
- // Server refetch updates the fallback automatically
-
- return
-}
-```
-
-### 6.3 useState Dispatch updaters for State That Depends on Current Value
-
-**Impact: MEDIUM (avoids stale closures, prevents unnecessary re-renders)**
-
-When the next state depends on the current state, use a dispatch updater
-
-(`setState(prev => ...)`) instead of reading the state variable directly in a
-
-callback. This avoids stale closures and ensures you're comparing against the
-
-latest value.
-
-**Incorrect: reads state directly**
-
-```tsx
-const [size, setSize] = useState(undefined)
-
-const onLayout = (e: LayoutChangeEvent) => {
- const { width, height } = e.nativeEvent.layout
- // size may be stale in this closure
- if (size?.width !== width || size?.height !== height) {
- setSize({ width, height })
- }
-}
-```
-
-**Correct: dispatch updater**
-
-```tsx
-const [size, setSize] = useState(undefined)
-
-const onLayout = (e: LayoutChangeEvent) => {
- const { width, height } = e.nativeEvent.layout
- setSize((prev) => {
- if (prev?.width === width && prev?.height === height) return prev
- return { width, height }
- })
-}
-```
-
-Returning the previous value from the updater skips the re-render.
-
-For primitive states, you don't need to compare values before firing a
-
-re-render.
-
-**Incorrect: unnecessary comparison for primitive state**
-
-```tsx
-const [size, setSize] = useState(undefined)
-
-const onLayout = (e: LayoutChangeEvent) => {
- const { width, height } = e.nativeEvent.layout
- setSize((prev) => (prev === width ? prev : width))
-}
-```
-
-**Correct: sets primitive state directly**
-
-```tsx
-const [size, setSize] = useState(undefined)
-
-const onLayout = (e: LayoutChangeEvent) => {
- const { width, height } = e.nativeEvent.layout
- setSize(width)
-}
-```
-
-However, if the next state depends on the current state, you should still use a
-
-dispatch updater.
-
-**Incorrect: reads state directly from the callback**
-
-```tsx
-const [count, setCount] = useState(0)
-
-const onTap = () => {
- setCount(count + 1)
-}
-```
-
-**Correct: dispatch updater**
-
-```tsx
-const [count, setCount] = useState(0)
-
-const onTap = () => {
- setCount((prev) => prev + 1)
-}
-```
-
----
-
-## 7. State Architecture
-
-**Impact: MEDIUM**
-
-Ground truth principles for state variables and derived values.
-
-### 7.1 State Must Represent Ground Truth
-
-**Impact: HIGH (cleaner logic, easier debugging, single source of truth)**
-
-State variables—both React `useState` and Reanimated shared values—should
-
-represent the actual state of something (e.g., `pressed`, `progress`, `isOpen`),
-
-not derived visual values (e.g., `scale`, `opacity`, `translateY`). Derive
-
-visual values from state using computation or interpolation.
-
-**Incorrect: storing the visual output**
-
-```tsx
-const scale = useSharedValue(1)
-
-const tap = Gesture.Tap()
- .onBegin(() => {
- scale.set(withTiming(0.95))
- })
- .onFinalize(() => {
- scale.set(withTiming(1))
- })
-
-const animatedStyle = useAnimatedStyle(() => ({
- transform: [{ scale: scale.get() }],
-}))
-```
-
-**Correct: storing the state, deriving the visual**
-
-```tsx
-const pressed = useSharedValue(0) // 0 = not pressed, 1 = pressed
-
-const tap = Gesture.Tap()
- .onBegin(() => {
- pressed.set(withTiming(1))
- })
- .onFinalize(() => {
- pressed.set(withTiming(0))
- })
-
-const animatedStyle = useAnimatedStyle(() => ({
- transform: [{ scale: interpolate(pressed.get(), [0, 1], [1, 0.95]) }],
-}))
-```
-
-**Why this matters:**
-
-State variables should represent real "state", not necessarily a desired end
-
-result.
-
-1. **Single source of truth** — The state (`pressed`) describes what's
-
- happening; visuals are derived
-
-2. **Easier to extend** — Adding opacity, rotation, or other effects just
-
- requires more interpolations from the same state
-
-3. **Debugging** — Inspecting `pressed = 1` is clearer than `scale = 0.95`
-
-4. **Reusable logic** — The same `pressed` value can drive multiple visual
-
- properties
-
-**Same principle for React state:**
-
-```tsx
-// Incorrect: storing derived values
-const [isExpanded, setIsExpanded] = useState(false)
-const [height, setHeight] = useState(0)
-
-useEffect(() => {
- setHeight(isExpanded ? 200 : 0)
-}, [isExpanded])
-
-// Correct: derive from state
-const [isExpanded, setIsExpanded] = useState(false)
-const height = isExpanded ? 200 : 0
-```
-
-State is the minimal truth. Everything else is derived.
-
----
-
-## 8. React Compiler
-
-**Impact: MEDIUM**
-
-Compatibility patterns for React Compiler with React Native and
-Reanimated.
-
-### 8.1 Destructure Functions Early in Render (React Compiler)
-
-**Impact: HIGH (stable references, fewer re-renders)**
-
-This rule is only applicable if you are using the React Compiler.
-
-Destructure functions from hooks at the top of render scope. Never dot into
-
-objects to call functions. Destructured functions are stable references; dotting
-
-creates new references and breaks memoization.
-
-**Incorrect: dotting into object**
-
-```tsx
-import { useRouter } from 'expo-router'
-
-function SaveButton(props) {
- const router = useRouter()
-
- // bad: react-compiler will key the cache on "props" and "router", which are objects that change each render
- const handlePress = () => {
- props.onSave()
- router.push('/success') // unstable reference
- }
-
- return
-}
-```
-
-**Correct: destructure early**
-
-```tsx
-import { useRouter } from 'expo-router'
-
-function SaveButton({ onSave }) {
- const { push } = useRouter()
-
- // good: react-compiler will key on push and onSave
- const handlePress = () => {
- onSave()
- push('/success') // stable reference
- }
-
- return
-}
-```
-
-### 8.2 Use .get() and .set() for Reanimated Shared Values (not .value)
-
-**Impact: LOW (required for React Compiler compatibility)**
-
-With React Compiler enabled, use `.get()` and `.set()` instead of reading or
-
-writing `.value` directly on Reanimated shared values. The compiler can't track
-
-property access—explicit methods ensure correct behavior.
-
-**Incorrect: breaks with React Compiler**
-
-```tsx
-import { useSharedValue } from 'react-native-reanimated'
-
-function Counter() {
- const count = useSharedValue(0)
-
- const increment = () => {
- count.value = count.value + 1 // opts out of react compiler
- }
-
- return
-}
-```
-
-**Correct: React Compiler compatible**
-
-```tsx
-import { useSharedValue } from 'react-native-reanimated'
-
-function Counter() {
- const count = useSharedValue(0)
-
- const increment = () => {
- count.set(count.get() + 1)
- }
-
- return
-}
-```
-
-See the
-
-[Reanimated docs](https://docs.swmansion.com/react-native-reanimated/docs/core/useSharedValue/#react-compiler-support)
-
-for more.
-
----
-
-## 9. User Interface
-
-**Impact: MEDIUM**
-
-Native UI patterns for images, menus, modals, styling, and
-platform-consistent interfaces.
-
-### 9.1 Measuring View Dimensions
-
-**Impact: MEDIUM (synchronous measurement, avoid unnecessary re-renders)**
-
-Use both `useLayoutEffect` (synchronous) and `onLayout` (for updates). The sync
-
-measurement gives you the initial size immediately; `onLayout` keeps it current
-
-when the view changes. For non-primitive states, use a dispatch updater to
-
-compare values and avoid unnecessary re-renders.
-
-**Height only:**
-
-```tsx
-import { useLayoutEffect, useRef, useState } from 'react'
-import { View, LayoutChangeEvent } from 'react-native'
-
-function MeasuredBox({ children }: { children: React.ReactNode }) {
- const ref = useRef(null)
- const [height, setHeight] = useState(undefined)
-
- useLayoutEffect(() => {
- // Sync measurement on mount (RN 0.82+)
- const rect = ref.current?.getBoundingClientRect()
- if (rect) setHeight(rect.height)
- // Pre-0.82: ref.current?.measure((x, y, w, h) => setHeight(h))
- }, [])
-
- const onLayout = (e: LayoutChangeEvent) => {
- setHeight(e.nativeEvent.layout.height)
- }
-
- return (
-
- {children}
-
- )
-}
-```
-
-**Both dimensions:**
-
-```tsx
-import { useLayoutEffect, useRef, useState } from 'react'
-import { View, LayoutChangeEvent } from 'react-native'
-
-type Size = { width: number; height: number }
-
-function MeasuredBox({ children }: { children: React.ReactNode }) {
- const ref = useRef(null)
- const [size, setSize] = useState(undefined)
-
- useLayoutEffect(() => {
- const rect = ref.current?.getBoundingClientRect()
- if (rect) setSize({ width: rect.width, height: rect.height })
- }, [])
-
- const onLayout = (e: LayoutChangeEvent) => {
- const { width, height } = e.nativeEvent.layout
- setSize((prev) => {
- // for non-primitive states, compare values before firing a re-render
- if (prev?.width === width && prev?.height === height) return prev
- return { width, height }
- })
- }
-
- return (
-
- {children}
-
- )
-}
-```
-
-Use functional setState to compare—don't read state directly in the callback.
-
-### 9.2 Modern React Native Styling Patterns
-
-**Impact: MEDIUM (consistent design, smoother borders, cleaner layouts)**
-
-Follow these styling patterns for cleaner, more consistent React Native code.
-
-**Always use `borderCurve: 'continuous'` with `borderRadius`:**
-
-**Use `gap` instead of margin for spacing between elements:**
-
-```tsx
-// Incorrect – margin on children
-
- Title
- Subtitle
-
-
-// Correct – gap on parent
-
- Title
- Subtitle
-
-```
-
-**Use `padding` for space within, `gap` for space between:**
-
-```tsx
-
- First
- Second
-
-```
-
-**Use `experimental_backgroundImage` for linear gradients:**
-
-```tsx
-// Incorrect – third-party gradient library
-
-
-// Correct – native CSS gradient syntax
-
-```
-
-**Use CSS `boxShadow` string syntax for shadows:**
-
-```tsx
-// Incorrect – legacy shadow objects or elevation
-{ shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1 }
-{ elevation: 4 }
-
-// Correct – CSS box-shadow syntax
-{ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)' }
-```
-
-**Avoid multiple font sizes – use weight and color for emphasis:**
-
-```tsx
-// Incorrect – varying font sizes for hierarchy
-Title
-Subtitle
-Caption
-
-// Correct – consistent size, vary weight and color
-Title
-Subtitle
-Caption
-```
-
-Limiting font sizes creates visual consistency. Use `fontWeight` (bold/semibold)
-
-and grayscale colors for hierarchy instead.
-
-### 9.3 Use contentInset for Dynamic ScrollView Spacing
-
-**Impact: LOW (smoother updates, no layout recalculation)**
-
-When adding space to the top or bottom of a ScrollView that may change
-
-(keyboard, toolbars, dynamic content), use `contentInset` instead of padding.
-
-Changing `contentInset` doesn't trigger layout recalculation—it adjusts the
-
-scroll area without re-rendering content.
-
-**Incorrect: padding causes layout recalculation**
-
-```tsx
-function Feed({ bottomOffset }: { bottomOffset: number }) {
- return (
-
- {children}
-
- )
-}
-// Changing bottomOffset triggers full layout recalculation
-```
-
-**Correct: contentInset for dynamic spacing**
-
-```tsx
-function Feed({ bottomOffset }: { bottomOffset: number }) {
- return (
-
- {children}
-
- )
-}
-// Changing bottomOffset only adjusts scroll bounds
-```
-
-Use `scrollIndicatorInsets` alongside `contentInset` to keep the scroll
-
-indicator aligned. For static spacing that never changes, padding is fine.
-
-### 9.4 Use contentInsetAdjustmentBehavior for Safe Areas
-
-**Impact: MEDIUM (native safe area handling, no layout shifts)**
-
-Use `contentInsetAdjustmentBehavior="automatic"` on the root ScrollView instead of wrapping content in SafeAreaView or manual padding. This lets iOS handle safe area insets natively with proper scroll behavior.
-
-**Incorrect: SafeAreaView wrapper**
-
-```tsx
-import { SafeAreaView, ScrollView, View, Text } from 'react-native'
-
-function MyScreen() {
- return (
-
-
-
- Content
-
-
-
- )
-}
-```
-
-**Incorrect: manual safe area padding**
-
-```tsx
-import { ScrollView, View, Text } from 'react-native'
-import { useSafeAreaInsets } from 'react-native-safe-area-context'
-
-function MyScreen() {
- const insets = useSafeAreaInsets()
-
- return (
-
-
- Content
-
-
- )
-}
-```
-
-**Correct: native content inset adjustment**
-
-```tsx
-import { ScrollView, View, Text } from 'react-native'
-
-function MyScreen() {
- return (
-
-
- Content
-
-
- )
-}
-```
-
-The native approach handles dynamic safe areas (keyboard, toolbars) and allows content to scroll behind the status bar naturally.
-
-### 9.5 Use expo-image for Optimized Images
-
-**Impact: HIGH (memory efficiency, caching, blurhash placeholders, progressive loading)**
-
-Use `expo-image` instead of React Native's `Image`. It provides memory-efficient caching, blurhash placeholders, progressive loading, and better performance for lists.
-
-**Incorrect: React Native Image**
-
-```tsx
-import { Image } from 'react-native'
-
-function Avatar({ url }: { url: string }) {
- return
-}
-```
-
-**Correct: expo-image**
-
-```tsx
-import { Image } from 'expo-image'
-
-function Avatar({ url }: { url: string }) {
- return
-}
-```
-
-**With blurhash placeholder:**
-
-```tsx
-
-```
-
-**With priority and caching:**
-
-```tsx
-
-```
-
-**Key props:**
-
-- `placeholder` — Blurhash or thumbnail while loading
-
-- `contentFit` — `cover`, `contain`, `fill`, `scale-down`
-
-- `transition` — Fade-in duration (ms)
-
-- `priority` — `low`, `normal`, `high`
-
-- `cachePolicy` — `memory`, `disk`, `memory-disk`, `none`
-
-- `recyclingKey` — Unique key for list recycling
-
-For cross-platform (web + native), use `SolitoImage` from `solito/image` which uses `expo-image` under the hood.
-
-Reference: [https://docs.expo.dev/versions/latest/sdk/image/](https://docs.expo.dev/versions/latest/sdk/image/)
-
-### 9.6 Use Galeria for Image Galleries and Lightbox
-
-**Impact: MEDIUM**
-
-For image galleries with lightbox (tap to fullscreen), use `@nandorojo/galeria`.
-
-It provides native shared element transitions with pinch-to-zoom, double-tap
-
-zoom, and pan-to-close. Works with any image component including `expo-image`.
-
-**Incorrect: custom modal implementation**
-
-```tsx
-function ImageGallery({ urls }: { urls: string[] }) {
- const [selected, setSelected] = useState(null)
-
- return (
- <>
- {urls.map((url) => (
- setSelected(url)}>
-
-
- ))}
- setSelected(null)}>
-
-
- >
- )
-}
-```
-
-**Correct: Galeria with expo-image**
-
-```tsx
-import { Galeria } from '@nandorojo/galeria'
-import { Image } from 'expo-image'
-
-function ImageGallery({ urls }: { urls: string[] }) {
- return (
-
- {urls.map((url, index) => (
-
-
-
- ))}
-
- )
-}
-```
-
-**Single image:**
-
-```tsx
-import { Galeria } from '@nandorojo/galeria'
-import { Image } from 'expo-image'
-
-function Avatar({ url }: { url: string }) {
- return (
-
-
-
-
-
- )
-}
-```
-
-**With low-res thumbnails and high-res fullscreen:**
-
-```tsx
-
- {lowResUrls.map((url, index) => (
-
-
-
- ))}
-
-```
-
-**With FlashList:**
-
-```tsx
-
- (
-
-
-
- )}
- numColumns={3}
- estimatedItemSize={100}
- />
-
-```
-
-Works with `expo-image`, `SolitoImage`, `react-native` Image, or any image
-
-component.
-
-Reference: [https://github.com/nandorojo/galeria](https://github.com/nandorojo/galeria)
-
-### 9.7 Use Native Menus for Dropdowns and Context Menus
-
-**Impact: HIGH (native accessibility, platform-consistent UX)**
-
-Use native platform menus instead of custom JS implementations. Native menus
-
-provide built-in accessibility, consistent platform UX, and better performance.
-
-Use [zeego](https://zeego.dev) for cross-platform native menus.
-
-**Incorrect: custom JS menu**
-
-```tsx
-import { useState } from 'react'
-import { View, Pressable, Text } from 'react-native'
-
-function MyMenu() {
- const [open, setOpen] = useState(false)
-
- return (
-
- setOpen(!open)}>
- Open Menu
-
- {open && (
-
- console.log('edit')}>
- Edit
-
- console.log('delete')}>
- Delete
-
-
- )}
-
- )
-}
-```
-
-**Correct: native menu with zeego**
-
-```tsx
-import * as DropdownMenu from 'zeego/dropdown-menu'
-
-function MyMenu() {
- return (
-
-
-
- Open Menu
-
-
-
-
- console.log('edit')}>
- Edit
-
-
- console.log('delete')}
- >
- Delete
-
-
-
- )
-}
-```
-
-**Context menu: long-press**
-
-```tsx
-import * as ContextMenu from 'zeego/context-menu'
-
-function MyContextMenu() {
- return (
-
-
-
- Long press me
-
-
-
-
- console.log('copy')}>
- Copy
-
-
- console.log('paste')}>
- Paste
-
-
-
- )
-}
-```
-
-**Checkbox items:**
-
-```tsx
-import * as DropdownMenu from 'zeego/dropdown-menu'
-
-function SettingsMenu() {
- const [notifications, setNotifications] = useState(true)
-
- return (
-
-
-
- Settings
-
-
-
-
- setNotifications((prev) => !prev)}
- >
-
- Notifications
-
-
-
- )
-}
-```
-
-**Submenus:**
-
-```tsx
-import * as DropdownMenu from 'zeego/dropdown-menu'
-
-function MenuWithSubmenu() {
- return (
-
-
-
- Options
-
-
-
-
- console.log('home')}>
- Home
-
-
-
-
- More Options
-
-
-
-
- Settings
-
-
-
- Help
-
-
-
-
-
- )
-}
-```
-
-Reference: [https://zeego.dev/components/dropdown-menu](https://zeego.dev/components/dropdown-menu)
-
-### 9.8 Use Native Modals Over JS-Based Bottom Sheets
-
-**Impact: HIGH (native performance, gestures, accessibility)**
-
-Use native `` with `presentationStyle="formSheet"` or React Navigation
-
-v7's native form sheet instead of JS-based bottom sheet libraries. Native modals
-
-have built-in gestures, accessibility, and better performance. Rely on native UI
-
-for low-level primitives.
-
-**Incorrect: JS-based bottom sheet**
-
-```tsx
-import BottomSheet from 'custom-js-bottom-sheet'
-
-function MyScreen() {
- const sheetRef = useRef(null)
-
- return (
-
-
- )
-}
-```
-
-**Correct: native Modal with formSheet**
-
-```tsx
-import { Modal, View, Text, Button } from 'react-native'
-
-function MyScreen() {
- const [visible, setVisible] = useState(false)
-
- return (
-
-
- )
-}
-```
-
-**Correct: React Navigation v7 native form sheet**
-
-```tsx
-// In your navigator
-
-```
-
-Native modals provide swipe-to-dismiss, proper keyboard avoidance, and
-
-accessibility out of the box.
-
-### 9.9 Use Pressable Instead of Touchable Components
-
-**Impact: LOW (modern API, more flexible)**
-
-Never use `TouchableOpacity` or `TouchableHighlight`. Use `Pressable` from
-
-`react-native` or `react-native-gesture-handler` instead.
-
-**Incorrect: legacy Touchable components**
-
-```tsx
-import { TouchableOpacity } from 'react-native'
-
-function MyButton({ onPress }: { onPress: () => void }) {
- return (
-
- Press me
-
- )
-}
-```
-
-**Correct: Pressable**
-
-```tsx
-import { Pressable } from 'react-native'
-
-function MyButton({ onPress }: { onPress: () => void }) {
- return (
-
- Press me
-
- )
-}
-```
-
-**Correct: Pressable from gesture handler for lists**
-
-```tsx
-import { Pressable } from 'react-native-gesture-handler'
-
-function ListItem({ onPress }: { onPress: () => void }) {
- return (
-
- Item
-
- )
-}
-```
-
-Use `react-native-gesture-handler` Pressable inside scrollable lists for better
-
-gesture coordination, as long as you are using the ScrollView from
-
-`react-native-gesture-handler` as well.
-
-**For animated press states (scale, opacity changes):** Use `GestureDetector`
-
-with Reanimated shared values instead of Pressable's style callback. See the
-
-`animation-gesture-detector-press` rule.
-
----
-
-## 10. Design System
-
-**Impact: MEDIUM**
-
-Architecture patterns for building maintainable component
-libraries.
-
-### 10.1 Use Compound Components Over Polymorphic Children
-
-**Impact: MEDIUM (flexible composition, clearer API)**
-
-Don't create components that can accept a string if they aren't a text node. If
-
-a component can receive a string child, it must be a dedicated `*Text`
-
-component. For components like buttons, which can have both a View (or
-
-Pressable) together with text, use compound components, such a `Button`,
-
-`ButtonText`, and `ButtonIcon`.
-
-**Incorrect: polymorphic children**
-
-```tsx
-import { Pressable, Text } from 'react-native'
-
-type ButtonProps = {
- children: string | React.ReactNode
- icon?: React.ReactNode
-}
-
-function Button({ children, icon }: ButtonProps) {
- return (
-
- {icon}
- {typeof children === 'string' ? {children} : children}
-
- )
-}
-
-// Usage is ambiguous
-}>Save
-
-```
-
-**Correct: compound components**
-
-```tsx
-import { Pressable, Text } from 'react-native'
-
-function Button({ children }: { children: React.ReactNode }) {
- return {children}
-}
-
-function ButtonText({ children }: { children: React.ReactNode }) {
- return {children}
-}
-
-function ButtonIcon({ children }: { children: React.ReactNode }) {
- return <>{children}>
-}
-
-// Usage is explicit and composable
-
-
-
-```
-
----
-
-## 11. Monorepo
-
-**Impact: LOW**
-
-Dependency management and native module configuration in
-monorepos.
-
-### 11.1 Install Native Dependencies in App Directory
-
-**Impact: CRITICAL (required for autolinking to work)**
-
-In a monorepo, packages with native code must be installed in the native app's
-
-directory directly. Autolinking only scans the app's `node_modules`—it won't
-
-find native dependencies installed in other packages.
-
-**Incorrect: native dep in shared package only**
-
-```typescript
-packages/
- ui/
- package.json # has react-native-reanimated
- app/
- package.json # missing react-native-reanimated
-```
-
-Autolinking fails—native code not linked.
-
-**Correct: native dep in app directory**
-
-```json
-// packages/app/package.json
-{
- "dependencies": {
- "react-native-reanimated": "3.16.1"
- }
-}
-```
-
-Even if the shared package uses the native dependency, the app must also list it
-
-for autolinking to detect and link the native code.
-
-### 11.2 Use Single Dependency Versions Across Monorepo
-
-**Impact: MEDIUM (avoids duplicate bundles, version conflicts)**
-
-Use a single version of each dependency across all packages in your monorepo.
-
-Prefer exact versions over ranges. Multiple versions cause duplicate code in
-
-bundles, runtime conflicts, and inconsistent behavior across packages.
-
-Use a tool like syncpack to enforce this. As a last resort, use yarn resolutions
-
-or npm overrides.
-
-**Incorrect: version ranges, multiple versions**
-
-```json
-// packages/app/package.json
-{
- "dependencies": {
- "react-native-reanimated": "^3.0.0"
- }
-}
-
-// packages/ui/package.json
-{
- "dependencies": {
- "react-native-reanimated": "^3.5.0"
- }
-}
-```
-
-**Correct: exact versions, single source of truth**
-
-```json
-// package.json (root)
-{
- "pnpm": {
- "overrides": {
- "react-native-reanimated": "3.16.1"
- }
- }
-}
-
-// packages/app/package.json
-{
- "dependencies": {
- "react-native-reanimated": "3.16.1"
- }
-}
-
-// packages/ui/package.json
-{
- "dependencies": {
- "react-native-reanimated": "3.16.1"
- }
-}
-```
-
-Use your package manager's override/resolution feature to enforce versions at
-
-the root. When adding dependencies, specify exact versions without `^` or `~`.
-
----
-
-## 12. Third-Party Dependencies
-
-**Impact: LOW**
-
-Wrapping and re-exporting third-party dependencies for
-maintainability.
-
-### 12.1 Import from Design System Folder
-
-**Impact: LOW (enables global changes and easy refactoring)**
-
-Re-export dependencies from a design system folder. App code imports from there,
-
-not directly from packages. This enables global changes and easy refactoring.
-
-**Incorrect: imports directly from package**
-
-```tsx
-import { View, Text } from 'react-native'
-import { Button } from '@ui/button'
-
-function Profile() {
- return (
-
- Hello
-
-
- )
-}
-```
-
-**Correct: imports from design system**
-
-```tsx
-import { View } from '@/components/view'
-import { Text } from '@/components/text'
-import { Button } from '@/components/button'
-
-function Profile() {
- return (
-
- Hello
-
-
- )
-}
-```
-
-Start by simply re-exporting. Customize later without changing app code.
-
----
-
-## 13. JavaScript
-
-**Impact: LOW**
-
-Micro-optimizations like hoisting expensive object creation.
-
-### 13.1 Hoist Intl Formatter Creation
-
-**Impact: LOW-MEDIUM (avoids expensive object recreation)**
-
-Don't create `Intl.DateTimeFormat`, `Intl.NumberFormat`, or
-
-`Intl.RelativeTimeFormat` inside render or loops. These are expensive to
-
-instantiate. Hoist to module scope when the locale/options are static.
-
-**Incorrect: new formatter every render**
-
-```tsx
-function Price({ amount }: { amount: number }) {
- const formatter = new Intl.NumberFormat('en-US', {
- style: 'currency',
- currency: 'USD',
- })
- return {formatter.format(amount)}
-}
-```
-
-**Correct: hoisted to module scope**
-
-```tsx
-const currencyFormatter = new Intl.NumberFormat('en-US', {
- style: 'currency',
- currency: 'USD',
-})
-
-function Price({ amount }: { amount: number }) {
- return {currencyFormatter.format(amount)}
-}
-```
-
-**For dynamic locales, memoize:**
-
-```tsx
-const dateFormatter = useMemo(
- () => new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }),
- [locale]
-)
-```
-
-**Common formatters to hoist:**
-
-```tsx
-// Module-level formatters
-const dateFormatter = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' })
-const timeFormatter = new Intl.DateTimeFormat('en-US', { timeStyle: 'short' })
-const percentFormatter = new Intl.NumberFormat('en-US', { style: 'percent' })
-const relativeFormatter = new Intl.RelativeTimeFormat('en-US', {
- numeric: 'auto',
-})
-```
-
-Creating `Intl` objects is significantly more expensive than `RegExp` or plain
-
-objects—each instantiation parses locale data and builds internal lookup tables.
-
----
-
-## 14. Fonts
-
-**Impact: LOW**
-
-Native font loading for improved performance.
-
-### 14.1 Load fonts natively at build time
-
-**Impact: LOW (fonts available at launch, no async loading)**
-
-Use the `expo-font` config plugin to embed fonts at build time instead of
-
-`useFonts` or `Font.loadAsync`. Embedded fonts are more efficient.
-
-[Expo Font Documentation](https://docs.expo.dev/versions/latest/sdk/font/)
-
-**Incorrect: async font loading**
-
-```tsx
-import { useFonts } from 'expo-font'
-import { Text, View } from 'react-native'
-
-function App() {
- const [fontsLoaded] = useFonts({
- 'Geist-Bold': require('./assets/fonts/Geist-Bold.otf'),
- })
-
- if (!fontsLoaded) {
- return null
- }
-
- return (
-
- Hello
-
- )
-}
-```
-
-**Correct: config plugin, fonts embedded at build**
-
-```tsx
-import { Text, View } from 'react-native'
-
-function App() {
- // No loading state needed—font is already available
- return (
-
- Hello
-
- )
-}
-```
-
-After adding fonts to the config plugin, run `npx expo prebuild` and rebuild the
-
-native app.
-
----
-
-## References
-
-1. [https://react.dev](https://react.dev)
-2. [https://reactnative.dev](https://reactnative.dev)
-3. [https://docs.swmansion.com/react-native-reanimated](https://docs.swmansion.com/react-native-reanimated)
-4. [https://docs.swmansion.com/react-native-gesture-handler](https://docs.swmansion.com/react-native-gesture-handler)
-5. [https://docs.expo.dev](https://docs.expo.dev)
-6. [https://legendapp.com/open-source/legend-list](https://legendapp.com/open-source/legend-list)
-7. [https://github.com/nandorojo/galeria](https://github.com/nandorojo/galeria)
-8. [https://zeego.dev](https://zeego.dev)
diff --git a/.agents/skills/vercel-react-native-skills/README.md b/.agents/skills/vercel-react-native-skills/README.md
deleted file mode 100644
index 854db9f..0000000
--- a/.agents/skills/vercel-react-native-skills/README.md
+++ /dev/null
@@ -1,165 +0,0 @@
-# React Native Guidelines
-
-A structured repository for creating and maintaining React Native Best Practices
-optimized for agents and LLMs.
-
-## Structure
-
-- `rules/` - Individual rule files (one per rule)
- - `_sections.md` - Section metadata (titles, impacts, descriptions)
- - `_template.md` - Template for creating new rules
- - `area-description.md` - Individual rule files
-- `metadata.json` - Document metadata (version, organization, abstract)
-- **`AGENTS.md`** - Compiled output (generated)
-
-## Rules
-
-### Core Rendering (CRITICAL)
-
-- `rendering-text-in-text-component.md` - Wrap strings in Text components
-- `rendering-no-falsy-and.md` - Avoid falsy && operator in JSX
-
-### List Performance (HIGH)
-
-- `list-performance-virtualize.md` - Use virtualized lists (LegendList,
- FlashList)
-- `list-performance-function-references.md` - Keep stable object references
-- `list-performance-callbacks.md` - Hoist callbacks to list root
-- `list-performance-inline-objects.md` - Avoid inline objects in renderItem
-- `list-performance-item-memo.md` - Pass primitives for memoization
-- `list-performance-item-expensive.md` - Keep list items lightweight
-- `list-performance-images.md` - Use compressed images in lists
-- `list-performance-item-types.md` - Use item types for heterogeneous lists
-
-### Animation (HIGH)
-
-- `animation-gpu-properties.md` - Animate transform/opacity instead of layout
-- `animation-gesture-detector-press.md` - Use GestureDetector for press
- animations
-- `animation-derived-value.md` - Prefer useDerivedValue over useAnimatedReaction
-
-### Scroll Performance (HIGH)
-
-- `scroll-position-no-state.md` - Never track scroll in useState
-
-### Navigation (HIGH)
-
-- `navigation-native-navigators.md` - Use native stack and native tabs
-
-### React State (MEDIUM)
-
-- `react-state-dispatcher.md` - Use functional setState updates
-- `react-state-fallback.md` - State should represent user intent only
-- `react-state-minimize.md` - Minimize state variables, derive values
-
-### State Architecture (MEDIUM)
-
-- `state-ground-truth.md` - State must represent ground truth
-
-### React Compiler (MEDIUM)
-
-- `react-compiler-destructure-functions.md` - Destructure functions early
-- `react-compiler-reanimated-shared-values.md` - Use .get()/.set() for shared
- values
-
-### User Interface (MEDIUM)
-
-- `ui-expo-image.md` - Use expo-image for optimized images
-- `ui-image-gallery.md` - Use Galeria for lightbox/galleries
-- `ui-menus.md` - Native dropdown and context menus with Zeego
-- `ui-native-modals.md` - Use native Modal with formSheet
-- `ui-pressable.md` - Use Pressable instead of TouchableOpacity
-- `ui-measure-views.md` - Measuring view dimensions
-- `ui-safe-area-scroll.md` - Use contentInsetAdjustmentBehavior
-- `ui-scrollview-content-inset.md` - Use contentInset for dynamic spacing
-- `ui-styling.md` - Modern styling patterns (gap, boxShadow, gradients)
-
-### Design System (MEDIUM)
-
-- `design-system-compound-components.md` - Use compound components
-
-### Monorepo (LOW)
-
-- `monorepo-native-deps-in-app.md` - Install native deps in app directory
-- `monorepo-single-dependency-versions.md` - Single dependency versions
-
-### Third-Party Dependencies (LOW)
-
-- `imports-design-system-folder.md` - Import from design system folder
-
-### JavaScript (LOW)
-
-- `js-hoist-intl.md` - Hoist Intl formatter creation
-
-### Fonts (LOW)
-
-- `fonts-config-plugin.md` - Load fonts natively at build time
-
-## Creating a New Rule
-
-1. Copy `rules/_template.md` to `rules/area-description.md`
-2. Choose the appropriate area prefix:
- - `rendering-` for Core Rendering
- - `list-performance-` for List Performance
- - `animation-` for Animation
- - `scroll-` for Scroll Performance
- - `navigation-` for Navigation
- - `react-state-` for React State
- - `state-` for State Architecture
- - `react-compiler-` for React Compiler
- - `ui-` for User Interface
- - `design-system-` for Design System
- - `monorepo-` for Monorepo
- - `imports-` for Third-Party Dependencies
- - `js-` for JavaScript
- - `fonts-` for Fonts
-3. Fill in the frontmatter and content
-4. Ensure you have clear examples with explanations
-
-## Rule File Structure
-
-Each rule file should follow this structure:
-
-````markdown
----
-title: Rule Title Here
-impact: MEDIUM
-impactDescription: Optional description
-tags: tag1, tag2, tag3
----
-
-## Rule Title Here
-
-Brief explanation of the rule and why it matters.
-
-**Incorrect (description of what's wrong):**
-
-```tsx
-// Bad code example
-```
-````
-
-**Correct (description of what's right):**
-
-```tsx
-// Good code example
-```
-
-Reference: [Link](https://example.com)
-
-```
-
-## File Naming Convention
-
-- Files starting with `_` are special (excluded from build)
-- Rule files: `area-description.md` (e.g., `animation-gpu-properties.md`)
-- Section is automatically inferred from filename prefix
-- Rules are sorted alphabetically by title within each section
-
-## Impact Levels
-
-- `CRITICAL` - Highest priority, causes crashes or broken UI
-- `HIGH` - Significant performance improvements
-- `MEDIUM` - Moderate performance improvements
-- `LOW` - Incremental improvements
-```
diff --git a/.agents/skills/vercel-react-native-skills/SKILL.md b/.agents/skills/vercel-react-native-skills/SKILL.md
deleted file mode 100644
index 7340186..0000000
--- a/.agents/skills/vercel-react-native-skills/SKILL.md
+++ /dev/null
@@ -1,121 +0,0 @@
----
-name: vercel-react-native-skills
-description:
- React Native and Expo best practices for building performant mobile apps. Use
- when building React Native components, optimizing list performance,
- implementing animations, or working with native modules. Triggers on tasks
- involving React Native, Expo, mobile performance, or native platform APIs.
-license: MIT
-metadata:
- author: vercel
- version: '1.0.0'
----
-
-# React Native Skills
-
-Comprehensive best practices for React Native and Expo applications. Contains
-rules across multiple categories covering performance, animations, UI patterns,
-and platform-specific optimizations.
-
-## When to Apply
-
-Reference these guidelines when:
-
-- Building React Native or Expo apps
-- Optimizing list and scroll performance
-- Implementing animations with Reanimated
-- Working with images and media
-- Configuring native modules or fonts
-- Structuring monorepo projects with native dependencies
-
-## Rule Categories by Priority
-
-| Priority | Category | Impact | Prefix |
-| -------- | ---------------- | -------- | -------------------- |
-| 1 | List Performance | CRITICAL | `list-performance-` |
-| 2 | Animation | HIGH | `animation-` |
-| 3 | Navigation | HIGH | `navigation-` |
-| 4 | UI Patterns | HIGH | `ui-` |
-| 5 | State Management | MEDIUM | `react-state-` |
-| 6 | Rendering | MEDIUM | `rendering-` |
-| 7 | Monorepo | MEDIUM | `monorepo-` |
-| 8 | Configuration | LOW | `fonts-`, `imports-` |
-
-## Quick Reference
-
-### 1. List Performance (CRITICAL)
-
-- `list-performance-virtualize` - Use FlashList for large lists
-- `list-performance-item-memo` - Memoize list item components
-- `list-performance-callbacks` - Stabilize callback references
-- `list-performance-inline-objects` - Avoid inline style objects
-- `list-performance-function-references` - Extract functions outside render
-- `list-performance-images` - Optimize images in lists
-- `list-performance-item-expensive` - Move expensive work outside items
-- `list-performance-item-types` - Use item types for heterogeneous lists
-
-### 2. Animation (HIGH)
-
-- `animation-gpu-properties` - Animate only transform and opacity
-- `animation-derived-value` - Use useDerivedValue for computed animations
-- `animation-gesture-detector-press` - Use Gesture.Tap instead of Pressable
-
-### 3. Navigation (HIGH)
-
-- `navigation-native-navigators` - Use native stack and native tabs over JS navigators
-
-### 4. UI Patterns (HIGH)
-
-- `ui-expo-image` - Use expo-image for all images
-- `ui-image-gallery` - Use Galeria for image lightboxes
-- `ui-pressable` - Use Pressable over TouchableOpacity
-- `ui-safe-area-scroll` - Handle safe areas in ScrollViews
-- `ui-scrollview-content-inset` - Use contentInset for headers
-- `ui-menus` - Use native context menus
-- `ui-native-modals` - Use native modals when possible
-- `ui-measure-views` - Use onLayout, not measure()
-- `ui-styling` - Use StyleSheet.create or Nativewind
-
-### 5. State Management (MEDIUM)
-
-- `react-state-minimize` - Minimize state subscriptions
-- `react-state-dispatcher` - Use dispatcher pattern for callbacks
-- `react-state-fallback` - Show fallback on first render
-- `react-compiler-destructure-functions` - Destructure for React Compiler
-- `react-compiler-reanimated-shared-values` - Handle shared values with compiler
-
-### 6. Rendering (MEDIUM)
-
-- `rendering-text-in-text-component` - Wrap text in Text components
-- `rendering-no-falsy-and` - Avoid falsy && for conditional rendering
-
-### 7. Monorepo (MEDIUM)
-
-- `monorepo-native-deps-in-app` - Keep native dependencies in app package
-- `monorepo-single-dependency-versions` - Use single versions across packages
-
-### 8. Configuration (LOW)
-
-- `fonts-config-plugin` - Use config plugins for custom fonts
-- `imports-design-system-folder` - Organize design system imports
-- `js-hoist-intl` - Hoist Intl object creation
-
-## How to Use
-
-Read individual rule files for detailed explanations and code examples:
-
-```
-rules/list-performance-virtualize.md
-rules/animation-gpu-properties.md
-```
-
-Each rule file contains:
-
-- Brief explanation of why it matters
-- Incorrect code example with explanation
-- Correct code example with explanation
-- Additional context and references
-
-## Full Compiled Document
-
-For the complete guide with all rules expanded: `AGENTS.md`
diff --git a/.agents/skills/vercel-react-native-skills/rules/animation-derived-value.md b/.agents/skills/vercel-react-native-skills/rules/animation-derived-value.md
deleted file mode 100644
index 310928a..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/animation-derived-value.md
+++ /dev/null
@@ -1,53 +0,0 @@
----
-title: Prefer useDerivedValue Over useAnimatedReaction
-impact: MEDIUM
-impactDescription: cleaner code, automatic dependency tracking
-tags: animation, reanimated, derived-value
----
-
-## Prefer useDerivedValue Over useAnimatedReaction
-
-When deriving a shared value from another, use `useDerivedValue` instead of
-`useAnimatedReaction`. Derived values are declarative, automatically track
-dependencies, and return a value you can use directly. Animated reactions are
-for side effects, not derivations.
-
-**Incorrect (useAnimatedReaction for derivation):**
-
-```tsx
-import { useSharedValue, useAnimatedReaction } from 'react-native-reanimated'
-
-function MyComponent() {
- const progress = useSharedValue(0)
- const opacity = useSharedValue(1)
-
- useAnimatedReaction(
- () => progress.value,
- (current) => {
- opacity.value = 1 - current
- }
- )
-
- // ...
-}
-```
-
-**Correct (useDerivedValue):**
-
-```tsx
-import { useSharedValue, useDerivedValue } from 'react-native-reanimated'
-
-function MyComponent() {
- const progress = useSharedValue(0)
-
- const opacity = useDerivedValue(() => 1 - progress.get())
-
- // ...
-}
-```
-
-Use `useAnimatedReaction` only for side effects that don't produce a value
-(e.g., triggering haptics, logging, calling `runOnJS`).
-
-Reference:
-[Reanimated useDerivedValue](https://docs.swmansion.com/react-native-reanimated/docs/core/useDerivedValue)
diff --git a/.agents/skills/vercel-react-native-skills/rules/animation-gesture-detector-press.md b/.agents/skills/vercel-react-native-skills/rules/animation-gesture-detector-press.md
deleted file mode 100644
index 87c6782..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/animation-gesture-detector-press.md
+++ /dev/null
@@ -1,95 +0,0 @@
----
-title: Use GestureDetector for Animated Press States
-impact: MEDIUM
-impactDescription: UI thread animations, smoother press feedback
-tags: animation, gestures, press, reanimated
----
-
-## Use GestureDetector for Animated Press States
-
-For animated press states (scale, opacity on press), use `GestureDetector` with
-`Gesture.Tap()` and shared values instead of Pressable's
-`onPressIn`/`onPressOut`. Gesture callbacks run on the UI thread as worklets—no
-JS thread round-trip for press animations.
-
-**Incorrect (Pressable with JS thread callbacks):**
-
-```tsx
-import { Pressable } from 'react-native'
-import Animated, {
- useSharedValue,
- useAnimatedStyle,
- withTiming,
-} from 'react-native-reanimated'
-
-function AnimatedButton({ onPress }: { onPress: () => void }) {
- const scale = useSharedValue(1)
-
- const animatedStyle = useAnimatedStyle(() => ({
- transform: [{ scale: scale.value }],
- }))
-
- return (
- (scale.value = withTiming(0.95))}
- onPressOut={() => (scale.value = withTiming(1))}
- >
-
- Press me
-
-
- )
-}
-```
-
-**Correct (GestureDetector with UI thread worklets):**
-
-```tsx
-import { Gesture, GestureDetector } from 'react-native-gesture-handler'
-import Animated, {
- useSharedValue,
- useAnimatedStyle,
- withTiming,
- interpolate,
- runOnJS,
-} from 'react-native-reanimated'
-
-function AnimatedButton({ onPress }: { onPress: () => void }) {
- // Store the press STATE (0 = not pressed, 1 = pressed)
- const pressed = useSharedValue(0)
-
- const tap = Gesture.Tap()
- .onBegin(() => {
- pressed.set(withTiming(1))
- })
- .onFinalize(() => {
- pressed.set(withTiming(0))
- })
- .onEnd(() => {
- runOnJS(onPress)()
- })
-
- // Derive visual values from the state
- const animatedStyle = useAnimatedStyle(() => ({
- transform: [
- { scale: interpolate(withTiming(pressed.get()), [0, 1], [1, 0.95]) },
- ],
- }))
-
- return (
-
-
- Press me
-
-
- )
-}
-```
-
-Store the press **state** (0 or 1), then derive the scale via `interpolate`.
-This keeps the shared value as ground truth. Use `runOnJS` to call JS functions
-from worklets. Use `.set()` and `.get()` for React Compiler compatibility.
-
-Reference:
-[Gesture Handler Tap Gesture](https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/tap-gesture)
diff --git a/.agents/skills/vercel-react-native-skills/rules/animation-gpu-properties.md b/.agents/skills/vercel-react-native-skills/rules/animation-gpu-properties.md
deleted file mode 100644
index 5fda095..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/animation-gpu-properties.md
+++ /dev/null
@@ -1,65 +0,0 @@
----
-title: Animate Transform and Opacity Instead of Layout Properties
-impact: HIGH
-impactDescription: GPU-accelerated animations, no layout recalculation
-tags: animation, performance, reanimated, transform, opacity
----
-
-## Animate Transform and Opacity Instead of Layout Properties
-
-Avoid animating `width`, `height`, `top`, `left`, `margin`, or `padding`. These trigger layout recalculation on every frame. Instead, use `transform` (scale, translate) and `opacity` which run on the GPU without triggering layout.
-
-**Incorrect (animates height, triggers layout every frame):**
-
-```tsx
-import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'
-
-function CollapsiblePanel({ expanded }: { expanded: boolean }) {
- const animatedStyle = useAnimatedStyle(() => ({
- height: withTiming(expanded ? 200 : 0), // triggers layout on every frame
- overflow: 'hidden',
- }))
-
- return {children}
-}
-```
-
-**Correct (animates scaleY, GPU-accelerated):**
-
-```tsx
-import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'
-
-function CollapsiblePanel({ expanded }: { expanded: boolean }) {
- const animatedStyle = useAnimatedStyle(() => ({
- transform: [
- { scaleY: withTiming(expanded ? 1 : 0) },
- ],
- opacity: withTiming(expanded ? 1 : 0),
- }))
-
- return (
-
- {children}
-
- )
-}
-```
-
-**Correct (animates translateY for slide animations):**
-
-```tsx
-import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'
-
-function SlideIn({ visible }: { visible: boolean }) {
- const animatedStyle = useAnimatedStyle(() => ({
- transform: [
- { translateY: withTiming(visible ? 0 : 100) },
- ],
- opacity: withTiming(visible ? 1 : 0),
- }))
-
- return {children}
-}
-```
-
-GPU-accelerated properties: `transform` (translate, scale, rotate), `opacity`. Everything else triggers layout.
diff --git a/.agents/skills/vercel-react-native-skills/rules/design-system-compound-components.md b/.agents/skills/vercel-react-native-skills/rules/design-system-compound-components.md
deleted file mode 100644
index d8239ee..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/design-system-compound-components.md
+++ /dev/null
@@ -1,66 +0,0 @@
----
-title: Use Compound Components Over Polymorphic Children
-impact: MEDIUM
-impactDescription: flexible composition, clearer API
-tags: design-system, components, composition
----
-
-## Use Compound Components Over Polymorphic Children
-
-Don't create components that can accept a string if they aren't a text node. If
-a component can receive a string child, it must be a dedicated `*Text`
-component. For components like buttons, which can have both a View (or
-Pressable) together with text, use compound components, such a `Button`,
-`ButtonText`, and `ButtonIcon`.
-
-**Incorrect (polymorphic children):**
-
-```tsx
-import { Pressable, Text } from 'react-native'
-
-type ButtonProps = {
- children: string | React.ReactNode
- icon?: React.ReactNode
-}
-
-function Button({ children, icon }: ButtonProps) {
- return (
-
- {icon}
- {typeof children === 'string' ? {children} : children}
-
- )
-}
-
-// Usage is ambiguous
-}>Save
-
-```
-
-**Correct (compound components):**
-
-```tsx
-import { Pressable, Text } from 'react-native'
-
-function Button({ children }: { children: React.ReactNode }) {
- return {children}
-}
-
-function ButtonText({ children }: { children: React.ReactNode }) {
- return {children}
-}
-
-function ButtonIcon({ children }: { children: React.ReactNode }) {
- return <>{children}>
-}
-
-// Usage is explicit and composable
-
-
-
-```
diff --git a/.agents/skills/vercel-react-native-skills/rules/fonts-config-plugin.md b/.agents/skills/vercel-react-native-skills/rules/fonts-config-plugin.md
deleted file mode 100644
index 39aa014..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/fonts-config-plugin.md
+++ /dev/null
@@ -1,71 +0,0 @@
----
-title: Load fonts natively at build time
-impact: LOW
-impactDescription: fonts available at launch, no async loading
-tags: fonts, expo, performance, config-plugin
----
-
-## Use Expo Config Plugin for Font Loading
-
-Use the `expo-font` config plugin to embed fonts at build time instead of
-`useFonts` or `Font.loadAsync`. Embedded fonts are more efficient.
-
-**Incorrect (async font loading):**
-
-```tsx
-import { useFonts } from 'expo-font'
-import { Text, View } from 'react-native'
-
-function App() {
- const [fontsLoaded] = useFonts({
- 'Geist-Bold': require('./assets/fonts/Geist-Bold.otf'),
- })
-
- if (!fontsLoaded) {
- return null
- }
-
- return (
-
- Hello
-
- )
-}
-```
-
-**Correct (config plugin, fonts embedded at build):**
-
-```json
-// app.json
-{
- "expo": {
- "plugins": [
- [
- "expo-font",
- {
- "fonts": ["./assets/fonts/Geist-Bold.otf"]
- }
- ]
- ]
- }
-}
-```
-
-```tsx
-import { Text, View } from 'react-native'
-
-function App() {
- // No loading state needed—font is already available
- return (
-
- Hello
-
- )
-}
-```
-
-After adding fonts to the config plugin, run `npx expo prebuild` and rebuild the
-native app.
-
-Reference:
-[Expo Font Documentation](https://docs.expo.dev/versions/latest/sdk/font/)
diff --git a/.agents/skills/vercel-react-native-skills/rules/imports-design-system-folder.md b/.agents/skills/vercel-react-native-skills/rules/imports-design-system-folder.md
deleted file mode 100644
index 8466dcb..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/imports-design-system-folder.md
+++ /dev/null
@@ -1,68 +0,0 @@
----
-title: Import from Design System Folder
-impact: LOW
-impactDescription: enables global changes and easy refactoring
-tags: imports, architecture, design-system
----
-
-## Import from Design System Folder
-
-Re-export dependencies from a design system folder. App code imports from there,
-not directly from packages. This enables global changes and easy refactoring.
-
-**Incorrect (imports directly from package):**
-
-```tsx
-import { View, Text } from 'react-native'
-import { Button } from '@ui/button'
-
-function Profile() {
- return (
-
- Hello
-
-
- )
-}
-```
-
-**Correct (imports from design system):**
-
-```tsx
-// components/view.tsx
-import { View as RNView } from 'react-native'
-
-// ideal: pick the props you will actually use to control implementation
-export function View(
- props: Pick, 'style' | 'children'>
-) {
- return
-}
-```
-
-```tsx
-// components/text.tsx
-export { Text } from 'react-native'
-```
-
-```tsx
-// components/button.tsx
-export { Button } from '@ui/button'
-```
-
-```tsx
-import { View } from '@/components/view'
-import { Text } from '@/components/text'
-import { Button } from '@/components/button'
-
-function Profile() {
- return (
-
- Hello
-
-
- )
-}
-```
-
-Start by simply re-exporting. Customize later without changing app code.
diff --git a/.agents/skills/vercel-react-native-skills/rules/js-hoist-intl.md b/.agents/skills/vercel-react-native-skills/rules/js-hoist-intl.md
deleted file mode 100644
index 9af1c35..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/js-hoist-intl.md
+++ /dev/null
@@ -1,61 +0,0 @@
----
-title: Hoist Intl Formatter Creation
-impact: LOW-MEDIUM
-impactDescription: avoids expensive object recreation
-tags: javascript, intl, optimization, memoization
----
-
-## Hoist Intl Formatter Creation
-
-Don't create `Intl.DateTimeFormat`, `Intl.NumberFormat`, or
-`Intl.RelativeTimeFormat` inside render or loops. These are expensive to
-instantiate. Hoist to module scope when the locale/options are static.
-
-**Incorrect (new formatter every render):**
-
-```tsx
-function Price({ amount }: { amount: number }) {
- const formatter = new Intl.NumberFormat('en-US', {
- style: 'currency',
- currency: 'USD',
- })
- return {formatter.format(amount)}
-}
-```
-
-**Correct (hoisted to module scope):**
-
-```tsx
-const currencyFormatter = new Intl.NumberFormat('en-US', {
- style: 'currency',
- currency: 'USD',
-})
-
-function Price({ amount }: { amount: number }) {
- return {currencyFormatter.format(amount)}
-}
-```
-
-**For dynamic locales, memoize:**
-
-```tsx
-const dateFormatter = useMemo(
- () => new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }),
- [locale]
-)
-```
-
-**Common formatters to hoist:**
-
-```tsx
-// Module-level formatters
-const dateFormatter = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' })
-const timeFormatter = new Intl.DateTimeFormat('en-US', { timeStyle: 'short' })
-const percentFormatter = new Intl.NumberFormat('en-US', { style: 'percent' })
-const relativeFormatter = new Intl.RelativeTimeFormat('en-US', {
- numeric: 'auto',
-})
-```
-
-Creating `Intl` objects is significantly more expensive than `RegExp` or plain
-objects—each instantiation parses locale data and builds internal lookup tables.
diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-callbacks.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-callbacks.md
deleted file mode 100644
index a0b3913..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/list-performance-callbacks.md
+++ /dev/null
@@ -1,44 +0,0 @@
----
-title: Hoist callbacks to the root of lists
-impact: MEDIUM
-impactDescription: Fewer re-renders and faster lists
-tags: tag1, tag2
----
-
-## List performance callbacks
-
-**Impact: HIGH (Fewer re-renders and faster lists)**
-
-When passing callback functions to list items, create a single instance of the
-callback at the root of the list. Items should then call it with a unique
-identifier.
-
-**Incorrect (creates a new callback on each render):**
-
-```typescript
-return (
- {
- // bad: creates a new callback on each render
- const onPress = () => handlePress(item.id)
- return
- }}
- />
-)
-```
-
-**Correct (a single function instance passed to each item):**
-
-```typescript
-const onPress = useCallback(() => handlePress(item.id), [handlePress, item.id])
-
-return (
- (
-
- )}
- />
-)
-```
-
-Reference: [Link to documentation or resource](https://example.com)
diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-function-references.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-function-references.md
deleted file mode 100644
index 9721929..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/list-performance-function-references.md
+++ /dev/null
@@ -1,132 +0,0 @@
----
-title: Optimize List Performance with Stable Object References
-impact: CRITICAL
-impactDescription: virtualization relies on reference stability
-tags: lists, performance, flatlist, virtualization
----
-
-## Optimize List Performance with Stable Object References
-
-Don't map or filter data before passing to virtualized lists. Virtualization
-relies on object reference stability to know what changed—new references cause
-full re-renders of all visible items. Attempt to prevent frequent renders at the
-list-parent level.
-
-Where needed, use context selectors within list items.
-
-**Incorrect (creates new object references on every keystroke):**
-
-```tsx
-function DomainSearch() {
- const { keyword, setKeyword } = useKeywordZustandState()
- const { data: tlds } = useTlds()
-
- // Bad: creates new objects on every render, reparenting the entire list on every keystroke
- const domains = tlds.map((tld) => ({
- domain: `${keyword}.${tld.name}`,
- tld: tld.name,
- price: tld.price,
- }))
-
- return (
- <>
-
- }
- />
- >
- )
-}
-```
-
-**Correct (stable references, transform inside items):**
-
-```tsx
-const renderItem = ({ item }) =>
-
-function DomainSearch() {
- const { data: tlds } = useTlds()
-
- return (
-
- )
-}
-
-function DomainItem({ tld }: { tld: Tld }) {
- // good: transform within items, and don't pass the dynamic data as a prop
- // good: use a selector function from zustand to receive a stable string back
- const domain = useKeywordZustandState((s) => s.keyword + '.' + tld.name)
- return {domain}
-}
-```
-
-**Updating parent array reference:**
-
-Creating a new array instance can be okay, as long as its inner object
-references are stable. For instance, if you sort a list of objects:
-
-```tsx
-// good: creates a new array instance without mutating the inner objects
-// good: parent array reference is unaffected by typing and updating "keyword"
-const sortedTlds = tlds.toSorted((a, b) => a.name.localeCompare(b.name))
-
-return
-```
-
-Even though this creates a new array instance `sortedTlds`, the inner object
-references are stable.
-
-**With zustand for dynamic data (avoids parent re-renders):**
-
-```tsx
-const useSearchStore = create<{ keyword: string }>(() => ({ keyword: '' }))
-
-function DomainSearch() {
- const { data: tlds } = useTlds()
-
- return (
- <>
-
- }
- />
- >
- )
-}
-
-function DomainItem({ tld }: { tld: Tld }) {
- // Select only what you need—component only re-renders when keyword changes
- const keyword = useSearchStore((s) => s.keyword)
- const domain = `${keyword}.${tld.name}`
- return {domain}
-}
-```
-
-Virtualization can now skip items that haven't changed when typing. Only visible
-items (~20) re-render on keystroke, rather than the parent.
-
-**Deriving state within list items based on parent data (avoids parent
-re-renders):**
-
-For components where the data is conditional based on the parent state, this
-pattern is even more important. For example, if you are checking if an item is
-favorited, toggling favorites only re-renders one component if the item itself
-is in charge of accessing the state rather than the parent:
-
-```tsx
-function DomainItemFavoriteButton({ tld }: { tld: Tld }) {
- const isFavorited = useFavoritesStore((s) => s.favorites.has(tld.id))
- return
-}
-```
-
-Note: if you're using the React Compiler, you can read React Context values
-directly within list items. Although this is slightly slower than using a
-Zustand selector in most cases, the effect may be negligible.
diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-images.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-images.md
deleted file mode 100644
index 75a3baf..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/list-performance-images.md
+++ /dev/null
@@ -1,53 +0,0 @@
----
-title: Use Compressed Images in Lists
-impact: HIGH
-impactDescription: faster load times, less memory
-tags: lists, images, performance, optimization
----
-
-## Use Compressed Images in Lists
-
-Always load compressed, appropriately-sized images in lists. Full-resolution
-images consume excessive memory and cause scroll jank. Request thumbnails from
-your server or use an image CDN with resize parameters.
-
-**Incorrect (full-resolution images):**
-
-```tsx
-function ProductItem({ product }: { product: Product }) {
- return (
-
- {/* 4000x3000 image loaded for a 100x100 thumbnail */}
-
- {product.name}
-
- )
-}
-```
-
-**Correct (request appropriately-sized image):**
-
-```tsx
-function ProductItem({ product }: { product: Product }) {
- // Request a 200x200 image (2x for retina)
- const thumbnailUrl = `${product.imageUrl}?w=200&h=200&fit=cover`
-
- return (
-
-
- {product.name}
-
- )
-}
-```
-
-Use an optimized image component with built-in caching and placeholder support,
-such as `expo-image` or `SolitoImage` (which uses `expo-image` under the hood).
-Request images at 2x the display size for retina screens.
diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-inline-objects.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-inline-objects.md
deleted file mode 100644
index d5b6514..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/list-performance-inline-objects.md
+++ /dev/null
@@ -1,97 +0,0 @@
----
-title: Avoid Inline Objects in renderItem
-impact: HIGH
-impactDescription: prevents unnecessary re-renders of memoized list items
-tags: lists, performance, flatlist, virtualization, memo
----
-
-## Avoid Inline Objects in renderItem
-
-Don't create new objects inside `renderItem` to pass as props. Inline objects
-create new references on every render, breaking memoization. Pass primitive
-values directly from `item` instead.
-
-**Incorrect (inline object breaks memoization):**
-
-```tsx
-function UserList({ users }: { users: User[] }) {
- return (
- (
-
- )}
- />
- )
-}
-```
-
-**Incorrect (inline style object):**
-
-```tsx
-renderItem={({ item }) => (
-
-)}
-```
-
-**Correct (pass item directly or primitives):**
-
-```tsx
-function UserList({ users }: { users: User[] }) {
- return (
- (
- // Good: pass the item directly
-
- )}
- />
- )
-}
-```
-
-**Correct (pass primitives, derive inside child):**
-
-```tsx
-renderItem={({ item }) => (
-
-)}
-
-const UserRow = memo(function UserRow({ id, name, isActive }: Props) {
- // Good: derive style inside memoized component
- const backgroundColor = isActive ? 'green' : 'gray'
- return {/* ... */}
-})
-```
-
-**Correct (hoist static styles in module scope):**
-
-```tsx
-const activeStyle = { backgroundColor: 'green' }
-const inactiveStyle = { backgroundColor: 'gray' }
-
-renderItem={({ item }) => (
-
-)}
-```
-
-Passing primitives or stable references allows `memo()` to skip re-renders when
-the actual values haven't changed.
-
-**Note:** If you have the React Compiler enabled, it handles memoization
-automatically and these manual optimizations become less critical.
diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-item-expensive.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-item-expensive.md
deleted file mode 100644
index f617a76..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/list-performance-item-expensive.md
+++ /dev/null
@@ -1,94 +0,0 @@
----
-title: Keep List Items Lightweight
-impact: HIGH
-impactDescription: reduces render time for visible items during scroll
-tags: lists, performance, virtualization, hooks
----
-
-## Keep List Items Lightweight
-
-List items should be as inexpensive as possible to render. Minimize hooks, avoid
-queries, and limit React Context access. Virtualized lists render many items
-during scroll—expensive items cause jank.
-
-**Incorrect (heavy list item):**
-
-```tsx
-function ProductRow({ id }: { id: string }) {
- // Bad: query inside list item
- const { data: product } = useQuery(['product', id], () => fetchProduct(id))
- // Bad: multiple context accesses
- const theme = useContext(ThemeContext)
- const user = useContext(UserContext)
- const cart = useContext(CartContext)
- // Bad: expensive computation
- const recommendations = useMemo(
- () => computeRecommendations(product),
- [product]
- )
-
- return {/* ... */}
-}
-```
-
-**Correct (lightweight list item):**
-
-```tsx
-function ProductRow({ name, price, imageUrl }: Props) {
- // Good: receives only primitives, minimal hooks
- return (
-
-
- {name}
- {price}
-
- )
-}
-```
-
-**Move data fetching to parent:**
-
-```tsx
-// Parent fetches all data once
-function ProductList() {
- const { data: products } = useQuery(['products'], fetchProducts)
-
- return (
- (
-
- )}
- />
- )
-}
-```
-
-**For shared values, use Zustand selectors instead of Context:**
-
-```tsx
-// Incorrect: Context causes re-render when any cart value changes
-function ProductRow({ id, name }: Props) {
- const { items } = useContext(CartContext)
- const inCart = items.includes(id)
- // ...
-}
-
-// Correct: Zustand selector only re-renders when this specific value changes
-function ProductRow({ id, name }: Props) {
- // use Set.has (created once at the root) instead of Array.includes()
- const inCart = useCartStore((s) => s.items.has(id))
- // ...
-}
-```
-
-**Guidelines for list items:**
-
-- No queries or data fetching
-- No expensive computations (move to parent or memoize at parent level)
-- Prefer Zustand selectors over React Context
-- Minimize useState/useEffect hooks
-- Pass pre-computed values as props
-
-The goal: list items should be simple rendering functions that take props and
-return JSX.
diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-item-memo.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-item-memo.md
deleted file mode 100644
index 634935e..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/list-performance-item-memo.md
+++ /dev/null
@@ -1,82 +0,0 @@
----
-title: Pass Primitives to List Items for Memoization
-impact: HIGH
-impactDescription: enables effective memo() comparison
-tags: lists, performance, memo, primitives
----
-
-## Pass Primitives to List Items for Memoization
-
-When possible, pass only primitive values (strings, numbers, booleans) as props
-to list item components. Primitives enable shallow comparison in `memo()` to
-work correctly, skipping re-renders when values haven't changed.
-
-**Incorrect (object prop requires deep comparison):**
-
-```tsx
-type User = { id: string; name: string; email: string; avatar: string }
-
-const UserRow = memo(function UserRow({ user }: { user: User }) {
- // memo() compares user by reference, not value
- // If parent creates new user object, this re-renders even if data is same
- return {user.name}
-})
-
-renderItem={({ item }) => }
-```
-
-This can still be optimized, but it is harder to memoize properly.
-
-**Correct (primitive props enable shallow comparison):**
-
-```tsx
-const UserRow = memo(function UserRow({
- id,
- name,
- email,
-}: {
- id: string
- name: string
- email: string
-}) {
- // memo() compares each primitive directly
- // Re-renders only if id, name, or email actually changed
- return {name}
-})
-
-renderItem={({ item }) => (
-
-)}
-```
-
-**Pass only what you need:**
-
-```tsx
-// Incorrect: passing entire item when you only need name
-
-
-// Correct: pass only the fields the component uses
-
-```
-
-**For callbacks, hoist or use item ID:**
-
-```tsx
-// Incorrect: inline function creates new reference
- handlePress(item.id)} />
-
-// Correct: pass ID, handle in child
-
-
-const UserRow = memo(function UserRow({ id, name }: Props) {
- const handlePress = useCallback(() => {
- // use id here
- }, [id])
- return {name}
-})
-```
-
-Primitive props make memoization predictable and effective.
-
-**Note:** If you have the React Compiler enabled, you do not need to use
-`memo()` or `useCallback()`, but the object references still apply.
diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-item-types.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-item-types.md
deleted file mode 100644
index 1027e4e..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/list-performance-item-types.md
+++ /dev/null
@@ -1,104 +0,0 @@
----
-title: Use Item Types for Heterogeneous Lists
-impact: HIGH
-impactDescription: efficient recycling, less layout thrashing
-tags: list, performance, recycling, heterogeneous, LegendList
----
-
-## Use Item Types for Heterogeneous Lists
-
-When a list has different item layouts (messages, images, headers, etc.), use a
-`type` field on each item and provide `getItemType` to the list. This puts items
-into separate recycling pools so a message component never gets recycled into an
-image component.
-
-**Incorrect (single component with conditionals):**
-
-```tsx
-type Item = { id: string; text?: string; imageUrl?: string; isHeader?: boolean }
-
-function ListItem({ item }: { item: Item }) {
- if (item.isHeader) {
- return
- }
- if (item.imageUrl) {
- return
- }
- return
-}
-
-function Feed({ items }: { items: Item[] }) {
- return (
- }
- recycleItems
- />
- )
-}
-```
-
-**Correct (typed items with separate components):**
-
-```tsx
-type HeaderItem = { id: string; type: 'header'; title: string }
-type MessageItem = { id: string; type: 'message'; text: string }
-type ImageItem = { id: string; type: 'image'; url: string }
-type FeedItem = HeaderItem | MessageItem | ImageItem
-
-function Feed({ items }: { items: FeedItem[] }) {
- return (
- item.id}
- getItemType={(item) => item.type}
- renderItem={({ item }) => {
- switch (item.type) {
- case 'header':
- return
- case 'message':
- return
- case 'image':
- return
- }
- }}
- recycleItems
- />
- )
-}
-```
-
-**Why this matters:**
-
-- **Recycling efficiency**: Items with the same type share a recycling pool
-- **No layout thrashing**: A header never recycles into an image cell
-- **Type safety**: TypeScript can narrow the item type in each branch
-- **Better size estimation**: Use `getEstimatedItemSize` with `itemType` for
- accurate estimates per type
-
-```tsx
- item.id}
- getItemType={(item) => item.type}
- getEstimatedItemSize={(index, item, itemType) => {
- switch (itemType) {
- case 'header':
- return 48
- case 'message':
- return 72
- case 'image':
- return 300
- default:
- return 72
- }
- }}
- renderItem={({ item }) => {
- /* ... */
- }}
- recycleItems
-/>
-```
-
-Reference:
-[LegendList getItemType](https://legendapp.com/open-source/list/api/props/#getitemtype-v2)
diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-virtualize.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-virtualize.md
deleted file mode 100644
index 8a393ba..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/list-performance-virtualize.md
+++ /dev/null
@@ -1,67 +0,0 @@
----
-title: Use a List Virtualizer for Any List
-impact: HIGH
-impactDescription: reduced memory, faster mounts
-tags: lists, performance, virtualization, scrollview
----
-
-## Use a List Virtualizer for Any List
-
-Use a list virtualizer like LegendList or FlashList instead of ScrollView with
-mapped children—even for short lists. Virtualizers only render visible items,
-reducing memory usage and mount time. ScrollView renders all children upfront,
-which gets expensive quickly.
-
-**Incorrect (ScrollView renders all items at once):**
-
-```tsx
-function Feed({ items }: { items: Item[] }) {
- return (
-
- {items.map((item) => (
-
- ))}
-
- )
-}
-// 50 items = 50 components mounted, even if only 10 visible
-```
-
-**Correct (virtualizer renders only visible items):**
-
-```tsx
-import { LegendList } from '@legendapp/list'
-
-function Feed({ items }: { items: Item[] }) {
- return (
- }
- keyExtractor={(item) => item.id}
- estimatedItemSize={80}
- />
- )
-}
-// Only ~10-15 visible items mounted at a time
-```
-
-**Alternative (FlashList):**
-
-```tsx
-import { FlashList } from '@shopify/flash-list'
-
-function Feed({ items }: { items: Item[] }) {
- return (
- }
- keyExtractor={(item) => item.id}
- />
- )
-}
-```
-
-Benefits apply to any screen with scrollable content—profiles, settings, feeds,
-search results. Default to virtualization.
diff --git a/.agents/skills/vercel-react-native-skills/rules/monorepo-native-deps-in-app.md b/.agents/skills/vercel-react-native-skills/rules/monorepo-native-deps-in-app.md
deleted file mode 100644
index ff85d76..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/monorepo-native-deps-in-app.md
+++ /dev/null
@@ -1,46 +0,0 @@
----
-title: Install Native Dependencies in App Directory
-impact: CRITICAL
-impactDescription: required for autolinking to work
-tags: monorepo, native, autolinking, installation
----
-
-## Install Native Dependencies in App Directory
-
-In a monorepo, packages with native code must be installed in the native app's
-directory directly. Autolinking only scans the app's `node_modules`—it won't
-find native dependencies installed in other packages.
-
-**Incorrect (native dep in shared package only):**
-
-```
-packages/
- ui/
- package.json # has react-native-reanimated
- app/
- package.json # missing react-native-reanimated
-```
-
-Autolinking fails—native code not linked.
-
-**Correct (native dep in app directory):**
-
-```
-packages/
- ui/
- package.json # has react-native-reanimated
- app/
- package.json # also has react-native-reanimated
-```
-
-```json
-// packages/app/package.json
-{
- "dependencies": {
- "react-native-reanimated": "3.16.1"
- }
-}
-```
-
-Even if the shared package uses the native dependency, the app must also list it
-for autolinking to detect and link the native code.
diff --git a/.agents/skills/vercel-react-native-skills/rules/monorepo-single-dependency-versions.md b/.agents/skills/vercel-react-native-skills/rules/monorepo-single-dependency-versions.md
deleted file mode 100644
index 1087dfa..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/monorepo-single-dependency-versions.md
+++ /dev/null
@@ -1,63 +0,0 @@
----
-title: Use Single Dependency Versions Across Monorepo
-impact: MEDIUM
-impactDescription: avoids duplicate bundles, version conflicts
-tags: monorepo, dependencies, installation
----
-
-## Use Single Dependency Versions Across Monorepo
-
-Use a single version of each dependency across all packages in your monorepo.
-Prefer exact versions over ranges. Multiple versions cause duplicate code in
-bundles, runtime conflicts, and inconsistent behavior across packages.
-
-Use a tool like syncpack to enforce this. As a last resort, use yarn resolutions
-or npm overrides.
-
-**Incorrect (version ranges, multiple versions):**
-
-```json
-// packages/app/package.json
-{
- "dependencies": {
- "react-native-reanimated": "^3.0.0"
- }
-}
-
-// packages/ui/package.json
-{
- "dependencies": {
- "react-native-reanimated": "^3.5.0"
- }
-}
-```
-
-**Correct (exact versions, single source of truth):**
-
-```json
-// package.json (root)
-{
- "pnpm": {
- "overrides": {
- "react-native-reanimated": "3.16.1"
- }
- }
-}
-
-// packages/app/package.json
-{
- "dependencies": {
- "react-native-reanimated": "3.16.1"
- }
-}
-
-// packages/ui/package.json
-{
- "dependencies": {
- "react-native-reanimated": "3.16.1"
- }
-}
-```
-
-Use your package manager's override/resolution feature to enforce versions at
-the root. When adding dependencies, specify exact versions without `^` or `~`.
diff --git a/.agents/skills/vercel-react-native-skills/rules/navigation-native-navigators.md b/.agents/skills/vercel-react-native-skills/rules/navigation-native-navigators.md
deleted file mode 100644
index 035c5fd..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/navigation-native-navigators.md
+++ /dev/null
@@ -1,188 +0,0 @@
----
-title: Use Native Navigators for Navigation
-impact: HIGH
-impactDescription: native performance, platform-appropriate UI
-tags: navigation, react-navigation, expo-router, native-stack, tabs
----
-
-## Use Native Navigators for Navigation
-
-Always use native navigators instead of JS-based ones. Native navigators use
-platform APIs (UINavigationController on iOS, Fragment on Android) for better
-performance and native behavior.
-
-**For stacks:** Use `@react-navigation/native-stack` or expo-router's default
-stack (which uses native-stack). Avoid `@react-navigation/stack`.
-
-**For tabs:** Use `react-native-bottom-tabs` (native) or expo-router's native
-tabs. Avoid `@react-navigation/bottom-tabs` when native feel matters.
-
-### Stack Navigation
-
-**Incorrect (JS stack navigator):**
-
-```tsx
-import { createStackNavigator } from '@react-navigation/stack'
-
-const Stack = createStackNavigator()
-
-function App() {
- return (
-
-
-
-
- )
-}
-```
-
-**Correct (native stack with react-navigation):**
-
-```tsx
-import { createNativeStackNavigator } from '@react-navigation/native-stack'
-
-const Stack = createNativeStackNavigator()
-
-function App() {
- return (
-
-
-
-
- )
-}
-```
-
-**Correct (expo-router uses native stack by default):**
-
-```tsx
-// app/_layout.tsx
-import { Stack } from 'expo-router'
-
-export default function Layout() {
- return
-}
-```
-
-### Tab Navigation
-
-**Incorrect (JS bottom tabs):**
-
-```tsx
-import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'
-
-const Tab = createBottomTabNavigator()
-
-function App() {
- return (
-
-
-
-
- )
-}
-```
-
-**Correct (native bottom tabs with react-navigation):**
-
-```tsx
-import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation'
-
-const Tab = createNativeBottomTabNavigator()
-
-function App() {
- return (
-
- ({ sfSymbol: 'house' }),
- }}
- />
- ({ sfSymbol: 'gear' }),
- }}
- />
-
- )
-}
-```
-
-**Correct (expo-router native tabs):**
-
-```tsx
-// app/(tabs)/_layout.tsx
-import { NativeTabs } from 'expo-router/unstable-native-tabs'
-
-export default function TabLayout() {
- return (
-
-
- Home
-
-
-
- Settings
-
-
-
- )
-}
-```
-
-On iOS, native tabs automatically enable `contentInsetAdjustmentBehavior` on the
-first `ScrollView` at the root of each tab screen, so content scrolls correctly
-behind the translucent tab bar. If you need to disable this, use
-`disableAutomaticContentInsets` on the trigger.
-
-### Prefer Native Header Options Over Custom Components
-
-**Incorrect (custom header component):**
-
-```tsx
- ,
- }}
-/>
-```
-
-**Correct (native header options):**
-
-```tsx
-
-```
-
-Native headers support iOS large titles, search bars, blur effects, and proper
-safe area handling automatically.
-
-### Why Native Navigators
-
-- **Performance**: Native transitions and gestures run on the UI thread
-- **Platform behavior**: Automatic iOS large titles, Android material design
-- **System integration**: Scroll-to-top on tab tap, PiP avoidance, proper safe
- areas
-- **Accessibility**: Platform accessibility features work automatically
-
-Reference:
-
-- [React Navigation Native Stack](https://reactnavigation.org/docs/native-stack-navigator)
-- [React Native Bottom Tabs with React Navigation](https://oss.callstack.com/react-native-bottom-tabs/docs/guides/usage-with-react-navigation)
-- [React Native Bottom Tabs with Expo Router](https://oss.callstack.com/react-native-bottom-tabs/docs/guides/usage-with-expo-router)
-- [Expo Router Native Tabs](https://docs.expo.dev/router/advanced/native-tabs)
diff --git a/.agents/skills/vercel-react-native-skills/rules/react-compiler-destructure-functions.md b/.agents/skills/vercel-react-native-skills/rules/react-compiler-destructure-functions.md
deleted file mode 100644
index f76c25a..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/react-compiler-destructure-functions.md
+++ /dev/null
@@ -1,50 +0,0 @@
----
-title: Destructure Functions Early in Render (React Compiler)
-impact: HIGH
-impactDescription: stable references, fewer re-renders
-tags: rerender, hooks, performance, react-compiler
----
-
-## Destructure Functions Early in Render
-
-This rule is only applicable if you are using the React Compiler.
-
-Destructure functions from hooks at the top of render scope. Never dot into
-objects to call functions. Destructured functions are stable references; dotting
-creates new references and breaks memoization.
-
-**Incorrect (dotting into object):**
-
-```tsx
-import { useRouter } from 'expo-router'
-
-function SaveButton(props) {
- const router = useRouter()
-
- // bad: react-compiler will key the cache on "props" and "router", which are objects that change each render
- const handlePress = () => {
- props.onSave()
- router.push('/success') // unstable reference
- }
-
- return
-}
-```
-
-**Correct (destructure early):**
-
-```tsx
-import { useRouter } from 'expo-router'
-
-function SaveButton({ onSave }) {
- const { push } = useRouter()
-
- // good: react-compiler will key on push and onSave
- const handlePress = () => {
- onSave()
- push('/success') // stable reference
- }
-
- return
-}
-```
diff --git a/.agents/skills/vercel-react-native-skills/rules/react-compiler-reanimated-shared-values.md b/.agents/skills/vercel-react-native-skills/rules/react-compiler-reanimated-shared-values.md
deleted file mode 100644
index 0dcbaf4..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/react-compiler-reanimated-shared-values.md
+++ /dev/null
@@ -1,48 +0,0 @@
----
-title: Use .get() and .set() for Reanimated Shared Values (not .value)
-impact: LOW
-impactDescription: required for React Compiler compatibility
-tags: reanimated, react-compiler, shared-values
----
-
-## Use .get() and .set() for Shared Values with React Compiler
-
-With React Compiler enabled, use `.get()` and `.set()` instead of reading or
-writing `.value` directly on Reanimated shared values. The compiler can't track
-property access—explicit methods ensure correct behavior.
-
-**Incorrect (breaks with React Compiler):**
-
-```tsx
-import { useSharedValue } from 'react-native-reanimated'
-
-function Counter() {
- const count = useSharedValue(0)
-
- const increment = () => {
- count.value = count.value + 1 // opts out of react compiler
- }
-
- return
-}
-```
-
-**Correct (React Compiler compatible):**
-
-```tsx
-import { useSharedValue } from 'react-native-reanimated'
-
-function Counter() {
- const count = useSharedValue(0)
-
- const increment = () => {
- count.set(count.get() + 1)
- }
-
- return
-}
-```
-
-See the
-[Reanimated docs](https://docs.swmansion.com/react-native-reanimated/docs/core/useSharedValue/#react-compiler-support)
-for more.
diff --git a/.agents/skills/vercel-react-native-skills/rules/react-state-dispatcher.md b/.agents/skills/vercel-react-native-skills/rules/react-state-dispatcher.md
deleted file mode 100644
index 93e8b6d..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/react-state-dispatcher.md
+++ /dev/null
@@ -1,91 +0,0 @@
----
-title: useState Dispatch updaters for State That Depends on Current Value
-impact: MEDIUM
-impactDescription: avoids stale closures, prevents unnecessary re-renders
-tags: state, hooks, useState, callbacks
----
-
-## Use Dispatch Updaters for State That Depends on Current Value
-
-When the next state depends on the current state, use a dispatch updater
-(`setState(prev => ...)`) instead of reading the state variable directly in a
-callback. This avoids stale closures and ensures you're comparing against the
-latest value.
-
-**Incorrect (reads state directly):**
-
-```tsx
-const [size, setSize] = useState(undefined)
-
-const onLayout = (e: LayoutChangeEvent) => {
- const { width, height } = e.nativeEvent.layout
- // size may be stale in this closure
- if (size?.width !== width || size?.height !== height) {
- setSize({ width, height })
- }
-}
-```
-
-**Correct (dispatch updater):**
-
-```tsx
-const [size, setSize] = useState(undefined)
-
-const onLayout = (e: LayoutChangeEvent) => {
- const { width, height } = e.nativeEvent.layout
- setSize((prev) => {
- if (prev?.width === width && prev?.height === height) return prev
- return { width, height }
- })
-}
-```
-
-Returning the previous value from the updater skips the re-render.
-
-For primitive states, you don't need to compare values before firing a
-re-render.
-
-**Incorrect (unnecessary comparison for primitive state):**
-
-```tsx
-const [size, setSize] = useState(undefined)
-
-const onLayout = (e: LayoutChangeEvent) => {
- const { width, height } = e.nativeEvent.layout
- setSize((prev) => (prev === width ? prev : width))
-}
-```
-
-**Correct (sets primitive state directly):**
-
-```tsx
-const [size, setSize] = useState(undefined)
-
-const onLayout = (e: LayoutChangeEvent) => {
- const { width, height } = e.nativeEvent.layout
- setSize(width)
-}
-```
-
-However, if the next state depends on the current state, you should still use a
-dispatch updater.
-
-**Incorrect (reads state directly from the callback):**
-
-```tsx
-const [count, setCount] = useState(0)
-
-const onTap = () => {
- setCount(count + 1)
-}
-```
-
-**Correct (dispatch updater):**
-
-```tsx
-const [count, setCount] = useState(0)
-
-const onTap = () => {
- setCount((prev) => prev + 1)
-}
-```
diff --git a/.agents/skills/vercel-react-native-skills/rules/react-state-fallback.md b/.agents/skills/vercel-react-native-skills/rules/react-state-fallback.md
deleted file mode 100644
index 204f346..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/react-state-fallback.md
+++ /dev/null
@@ -1,56 +0,0 @@
----
-title: Use fallback state instead of initialState
-impact: MEDIUM
-impactDescription: reactive fallbacks without syncing
-tags: state, hooks, derived-state, props, initialState
----
-
-## Use fallback state instead of initialState
-
-Use `undefined` as initial state and nullish coalescing (`??`) to fall back to
-parent or server values. State represents user intent only—`undefined` means
-"user hasn't chosen yet." This enables reactive fallbacks that update when the
-source changes, not just on initial render.
-
-**Incorrect (syncs state, loses reactivity):**
-
-```tsx
-type Props = { fallbackEnabled: boolean }
-
-function Toggle({ fallbackEnabled }: Props) {
- const [enabled, setEnabled] = useState(defaultEnabled)
- // If fallbackEnabled changes, state is stale
- // State mixes user intent with default value
-
- return
-}
-```
-
-**Correct (state is user intent, reactive fallback):**
-
-```tsx
-type Props = { fallbackEnabled: boolean }
-
-function Toggle({ fallbackEnabled }: Props) {
- const [_enabled, setEnabled] = useState(undefined)
- const enabled = _enabled ?? defaultEnabled
- // undefined = user hasn't touched it, falls back to prop
- // If defaultEnabled changes, component reflects it
- // Once user interacts, their choice persists
-
- return
-}
-```
-
-**With server data:**
-
-```tsx
-function ProfileForm({ data }: { data: User }) {
- const [_theme, setTheme] = useState(undefined)
- const theme = _theme ?? data.theme
- // Shows server value until user overrides
- // Server refetch updates the fallback automatically
-
- return
-}
-```
diff --git a/.agents/skills/vercel-react-native-skills/rules/react-state-minimize.md b/.agents/skills/vercel-react-native-skills/rules/react-state-minimize.md
deleted file mode 100644
index 64605b6..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/react-state-minimize.md
+++ /dev/null
@@ -1,65 +0,0 @@
----
-title: Minimize State Variables and Derive Values
-impact: MEDIUM
-impactDescription: fewer re-renders, less state drift
-tags: state, derived-state, hooks, optimization
----
-
-## Minimize State Variables and Derive Values
-
-Use the fewest state variables possible. If a value can be computed from existing state or props, derive it during render instead of storing it in state. Redundant state causes unnecessary re-renders and can drift out of sync.
-
-**Incorrect (redundant state):**
-
-```tsx
-function Cart({ items }: { items: Item[] }) {
- const [total, setTotal] = useState(0)
- const [itemCount, setItemCount] = useState(0)
-
- useEffect(() => {
- setTotal(items.reduce((sum, item) => sum + item.price, 0))
- setItemCount(items.length)
- }, [items])
-
- return (
-
- {itemCount} items
- Total: ${total}
-
- )
-}
-```
-
-**Correct (derived values):**
-
-```tsx
-function Cart({ items }: { items: Item[] }) {
- const total = items.reduce((sum, item) => sum + item.price, 0)
- const itemCount = items.length
-
- return (
-
- {itemCount} items
- Total: ${total}
-
- )
-}
-```
-
-**Another example:**
-
-```tsx
-// Incorrect: storing both firstName, lastName, AND fullName
-const [firstName, setFirstName] = useState('')
-const [lastName, setLastName] = useState('')
-const [fullName, setFullName] = useState('')
-
-// Correct: derive fullName
-const [firstName, setFirstName] = useState('')
-const [lastName, setLastName] = useState('')
-const fullName = `${firstName} ${lastName}`
-```
-
-State should be the minimal source of truth. Everything else is derived.
-
-Reference: [Choosing the State Structure](https://react.dev/learn/choosing-the-state-structure)
diff --git a/.agents/skills/vercel-react-native-skills/rules/rendering-no-falsy-and.md b/.agents/skills/vercel-react-native-skills/rules/rendering-no-falsy-and.md
deleted file mode 100644
index 30f05d3..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/rendering-no-falsy-and.md
+++ /dev/null
@@ -1,74 +0,0 @@
----
-title: Never Use && with Potentially Falsy Values
-impact: CRITICAL
-impactDescription: prevents production crash
-tags: rendering, conditional, jsx, crash
----
-
-## Never Use && with Potentially Falsy Values
-
-Never use `{value && }` when `value` could be an empty string or
-`0`. These are falsy but JSX-renderable—React Native will try to render them as
-text outside a `` component, causing a hard crash in production.
-
-**Incorrect (crashes if count is 0 or name is ""):**
-
-```tsx
-function Profile({ name, count }: { name: string; count: number }) {
- return (
-
- {name && {name}}
- {count && {count} items}
-
- )
-}
-// If name="" or count=0, renders the falsy value → crash
-```
-
-**Correct (ternary with null):**
-
-```tsx
-function Profile({ name, count }: { name: string; count: number }) {
- return (
-
- {name ? {name} : null}
- {count ? {count} items : null}
-
- )
-}
-```
-
-**Correct (explicit boolean coercion):**
-
-```tsx
-function Profile({ name, count }: { name: string; count: number }) {
- return (
-
- {!!name && {name}}
- {!!count && {count} items}
-
- )
-}
-```
-
-**Best (early return):**
-
-```tsx
-function Profile({ name, count }: { name: string; count: number }) {
- if (!name) return null
-
- return (
-
- {name}
- {count > 0 ? {count} items : null}
-
- )
-}
-```
-
-Early returns are clearest. When using conditionals inline, prefer ternary or
-explicit boolean checks.
-
-**Lint rule:** Enable `react/jsx-no-leaked-render` from
-[eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-no-leaked-render.md)
-to catch this automatically.
diff --git a/.agents/skills/vercel-react-native-skills/rules/rendering-text-in-text-component.md b/.agents/skills/vercel-react-native-skills/rules/rendering-text-in-text-component.md
deleted file mode 100644
index fd1b9f4..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/rendering-text-in-text-component.md
+++ /dev/null
@@ -1,36 +0,0 @@
----
-title: Wrap Strings in Text Components
-impact: CRITICAL
-impactDescription: prevents runtime crash
-tags: rendering, text, core
----
-
-## Wrap Strings in Text Components
-
-Strings must be rendered inside ``. React Native crashes if a string is a
-direct child of ``.
-
-**Incorrect (crashes):**
-
-```tsx
-import { View } from 'react-native'
-
-function Greeting({ name }: { name: string }) {
- return Hello, {name}!
-}
-// Error: Text strings must be rendered within a component.
-```
-
-**Correct:**
-
-```tsx
-import { View, Text } from 'react-native'
-
-function Greeting({ name }: { name: string }) {
- return (
-
- Hello, {name}!
-
- )
-}
-```
diff --git a/.agents/skills/vercel-react-native-skills/rules/scroll-position-no-state.md b/.agents/skills/vercel-react-native-skills/rules/scroll-position-no-state.md
deleted file mode 100644
index a5760cd..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/scroll-position-no-state.md
+++ /dev/null
@@ -1,82 +0,0 @@
----
-title: Never Track Scroll Position in useState
-impact: HIGH
-impactDescription: prevents render thrashing during scroll
-tags: scroll, performance, reanimated, useRef
----
-
-## Never Track Scroll Position in useState
-
-Never store scroll position in `useState`. Scroll events fire rapidly—state
-updates cause render thrashing and dropped frames. Use a Reanimated shared value
-for animations or a ref for non-reactive tracking.
-
-**Incorrect (useState causes jank):**
-
-```tsx
-import { useState } from 'react'
-import {
- ScrollView,
- NativeSyntheticEvent,
- NativeScrollEvent,
-} from 'react-native'
-
-function Feed() {
- const [scrollY, setScrollY] = useState(0)
-
- const onScroll = (e: NativeSyntheticEvent) => {
- setScrollY(e.nativeEvent.contentOffset.y) // re-renders on every frame
- }
-
- return
-}
-```
-
-**Correct (Reanimated for animations):**
-
-```tsx
-import Animated, {
- useSharedValue,
- useAnimatedScrollHandler,
-} from 'react-native-reanimated'
-
-function Feed() {
- const scrollY = useSharedValue(0)
-
- const onScroll = useAnimatedScrollHandler({
- onScroll: (e) => {
- scrollY.value = e.contentOffset.y // runs on UI thread, no re-render
- },
- })
-
- return (
-
- )
-}
-```
-
-**Correct (ref for non-reactive tracking):**
-
-```tsx
-import { useRef } from 'react'
-import {
- ScrollView,
- NativeSyntheticEvent,
- NativeScrollEvent,
-} from 'react-native'
-
-function Feed() {
- const scrollY = useRef(0)
-
- const onScroll = (e: NativeSyntheticEvent) => {
- scrollY.current = e.nativeEvent.contentOffset.y // no re-render
- }
-
- return
-}
-```
diff --git a/.agents/skills/vercel-react-native-skills/rules/state-ground-truth.md b/.agents/skills/vercel-react-native-skills/rules/state-ground-truth.md
deleted file mode 100644
index c3c4bd9..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/state-ground-truth.md
+++ /dev/null
@@ -1,80 +0,0 @@
----
-title: State Must Represent Ground Truth
-impact: HIGH
-impactDescription: cleaner logic, easier debugging, single source of truth
-tags: state, derived-state, reanimated, hooks
----
-
-## State Must Represent Ground Truth
-
-State variables—both React `useState` and Reanimated shared values—should
-represent the actual state of something (e.g., `pressed`, `progress`, `isOpen`),
-not derived visual values (e.g., `scale`, `opacity`, `translateY`). Derive
-visual values from state using computation or interpolation.
-
-**Incorrect (storing the visual output):**
-
-```tsx
-const scale = useSharedValue(1)
-
-const tap = Gesture.Tap()
- .onBegin(() => {
- scale.set(withTiming(0.95))
- })
- .onFinalize(() => {
- scale.set(withTiming(1))
- })
-
-const animatedStyle = useAnimatedStyle(() => ({
- transform: [{ scale: scale.get() }],
-}))
-```
-
-**Correct (storing the state, deriving the visual):**
-
-```tsx
-const pressed = useSharedValue(0) // 0 = not pressed, 1 = pressed
-
-const tap = Gesture.Tap()
- .onBegin(() => {
- pressed.set(withTiming(1))
- })
- .onFinalize(() => {
- pressed.set(withTiming(0))
- })
-
-const animatedStyle = useAnimatedStyle(() => ({
- transform: [{ scale: interpolate(pressed.get(), [0, 1], [1, 0.95]) }],
-}))
-```
-
-**Why this matters:**
-
-State variables should represent real "state", not necessarily a desired end
-result.
-
-1. **Single source of truth** — The state (`pressed`) describes what's
- happening; visuals are derived
-2. **Easier to extend** — Adding opacity, rotation, or other effects just
- requires more interpolations from the same state
-3. **Debugging** — Inspecting `pressed = 1` is clearer than `scale = 0.95`
-4. **Reusable logic** — The same `pressed` value can drive multiple visual
- properties
-
-**Same principle for React state:**
-
-```tsx
-// Incorrect: storing derived values
-const [isExpanded, setIsExpanded] = useState(false)
-const [height, setHeight] = useState(0)
-
-useEffect(() => {
- setHeight(isExpanded ? 200 : 0)
-}, [isExpanded])
-
-// Correct: derive from state
-const [isExpanded, setIsExpanded] = useState(false)
-const height = isExpanded ? 200 : 0
-```
-
-State is the minimal truth. Everything else is derived.
diff --git a/.agents/skills/vercel-react-native-skills/rules/ui-expo-image.md b/.agents/skills/vercel-react-native-skills/rules/ui-expo-image.md
deleted file mode 100644
index 72d768f..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/ui-expo-image.md
+++ /dev/null
@@ -1,66 +0,0 @@
----
-title: Use expo-image for Optimized Images
-impact: HIGH
-impactDescription: memory efficiency, caching, blurhash placeholders, progressive loading
-tags: images, performance, expo-image, ui
----
-
-## Use expo-image for Optimized Images
-
-Use `expo-image` instead of React Native's `Image`. It provides memory-efficient caching, blurhash placeholders, progressive loading, and better performance for lists.
-
-**Incorrect (React Native Image):**
-
-```tsx
-import { Image } from 'react-native'
-
-function Avatar({ url }: { url: string }) {
- return
-}
-```
-
-**Correct (expo-image):**
-
-```tsx
-import { Image } from 'expo-image'
-
-function Avatar({ url }: { url: string }) {
- return
-}
-```
-
-**With blurhash placeholder:**
-
-```tsx
-
-```
-
-**With priority and caching:**
-
-```tsx
-
-```
-
-**Key props:**
-
-- `placeholder` — Blurhash or thumbnail while loading
-- `contentFit` — `cover`, `contain`, `fill`, `scale-down`
-- `transition` — Fade-in duration (ms)
-- `priority` — `low`, `normal`, `high`
-- `cachePolicy` — `memory`, `disk`, `memory-disk`, `none`
-- `recyclingKey` — Unique key for list recycling
-
-For cross-platform (web + native), use `SolitoImage` from `solito/image` which uses `expo-image` under the hood.
-
-Reference: [expo-image](https://docs.expo.dev/versions/latest/sdk/image/)
diff --git a/.agents/skills/vercel-react-native-skills/rules/ui-image-gallery.md b/.agents/skills/vercel-react-native-skills/rules/ui-image-gallery.md
deleted file mode 100644
index ef26d96..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/ui-image-gallery.md
+++ /dev/null
@@ -1,104 +0,0 @@
----
-title: Use Galeria for Image Galleries and Lightbox
-impact: MEDIUM
-impactDescription:
- native shared element transitions, pinch-to-zoom, pan-to-close
-tags: images, gallery, lightbox, expo-image, ui
----
-
-## Use Galeria for Image Galleries and Lightbox
-
-For image galleries with lightbox (tap to fullscreen), use `@nandorojo/galeria`.
-It provides native shared element transitions with pinch-to-zoom, double-tap
-zoom, and pan-to-close. Works with any image component including `expo-image`.
-
-**Incorrect (custom modal implementation):**
-
-```tsx
-function ImageGallery({ urls }: { urls: string[] }) {
- const [selected, setSelected] = useState(null)
-
- return (
- <>
- {urls.map((url) => (
- setSelected(url)}>
-
-
- ))}
- setSelected(null)}>
-
-
- >
- )
-}
-```
-
-**Correct (Galeria with expo-image):**
-
-```tsx
-import { Galeria } from '@nandorojo/galeria'
-import { Image } from 'expo-image'
-
-function ImageGallery({ urls }: { urls: string[] }) {
- return (
-
- {urls.map((url, index) => (
-
-
-
- ))}
-
- )
-}
-```
-
-**Single image:**
-
-```tsx
-import { Galeria } from '@nandorojo/galeria'
-import { Image } from 'expo-image'
-
-function Avatar({ url }: { url: string }) {
- return (
-
-
-
-
-
- )
-}
-```
-
-**With low-res thumbnails and high-res fullscreen:**
-
-```tsx
-
- {lowResUrls.map((url, index) => (
-
-
-
- ))}
-
-```
-
-**With FlashList:**
-
-```tsx
-
- (
-
-
-
- )}
- numColumns={3}
- estimatedItemSize={100}
- />
-
-```
-
-Works with `expo-image`, `SolitoImage`, `react-native` Image, or any image
-component.
-
-Reference: [Galeria](https://github.com/nandorojo/galeria)
diff --git a/.agents/skills/vercel-react-native-skills/rules/ui-measure-views.md b/.agents/skills/vercel-react-native-skills/rules/ui-measure-views.md
deleted file mode 100644
index 8b783fe..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/ui-measure-views.md
+++ /dev/null
@@ -1,78 +0,0 @@
----
-title: Measuring View Dimensions
-impact: MEDIUM
-impactDescription: synchronous measurement, avoid unnecessary re-renders
-tags: layout, measurement, onLayout, useLayoutEffect
----
-
-## Measuring View Dimensions
-
-Use both `useLayoutEffect` (synchronous) and `onLayout` (for updates). The sync
-measurement gives you the initial size immediately; `onLayout` keeps it current
-when the view changes. For non-primitive states, use a dispatch updater to
-compare values and avoid unnecessary re-renders.
-
-**Height only:**
-
-```tsx
-import { useLayoutEffect, useRef, useState } from 'react'
-import { View, LayoutChangeEvent } from 'react-native'
-
-function MeasuredBox({ children }: { children: React.ReactNode }) {
- const ref = useRef(null)
- const [height, setHeight] = useState(undefined)
-
- useLayoutEffect(() => {
- // Sync measurement on mount (RN 0.82+)
- const rect = ref.current?.getBoundingClientRect()
- if (rect) setHeight(rect.height)
- // Pre-0.82: ref.current?.measure((x, y, w, h) => setHeight(h))
- }, [])
-
- const onLayout = (e: LayoutChangeEvent) => {
- setHeight(e.nativeEvent.layout.height)
- }
-
- return (
-
- {children}
-
- )
-}
-```
-
-**Both dimensions:**
-
-```tsx
-import { useLayoutEffect, useRef, useState } from 'react'
-import { View, LayoutChangeEvent } from 'react-native'
-
-type Size = { width: number; height: number }
-
-function MeasuredBox({ children }: { children: React.ReactNode }) {
- const ref = useRef(null)
- const [size, setSize] = useState(undefined)
-
- useLayoutEffect(() => {
- const rect = ref.current?.getBoundingClientRect()
- if (rect) setSize({ width: rect.width, height: rect.height })
- }, [])
-
- const onLayout = (e: LayoutChangeEvent) => {
- const { width, height } = e.nativeEvent.layout
- setSize((prev) => {
- // for non-primitive states, compare values before firing a re-render
- if (prev?.width === width && prev?.height === height) return prev
- return { width, height }
- })
- }
-
- return (
-
- {children}
-
- )
-}
-```
-
-Use functional setState to compare—don't read state directly in the callback.
diff --git a/.agents/skills/vercel-react-native-skills/rules/ui-menus.md b/.agents/skills/vercel-react-native-skills/rules/ui-menus.md
deleted file mode 100644
index 5168bc2..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/ui-menus.md
+++ /dev/null
@@ -1,174 +0,0 @@
----
-title: Use Native Menus for Dropdowns and Context Menus
-impact: HIGH
-impactDescription: native accessibility, platform-consistent UX
-tags: user-interface, menus, context-menus, zeego, accessibility
----
-
-## Use Native Menus for Dropdowns and Context Menus
-
-Use native platform menus instead of custom JS implementations. Native menus
-provide built-in accessibility, consistent platform UX, and better performance.
-Use [zeego](https://zeego.dev) for cross-platform native menus.
-
-**Incorrect (custom JS menu):**
-
-```tsx
-import { useState } from 'react'
-import { View, Pressable, Text } from 'react-native'
-
-function MyMenu() {
- const [open, setOpen] = useState(false)
-
- return (
-
- setOpen(!open)}>
- Open Menu
-
- {open && (
-
- console.log('edit')}>
- Edit
-
- console.log('delete')}>
- Delete
-
-
- )}
-
- )
-}
-```
-
-**Correct (native menu with zeego):**
-
-```tsx
-import * as DropdownMenu from 'zeego/dropdown-menu'
-
-function MyMenu() {
- return (
-
-
-
- Open Menu
-
-
-
-
- console.log('edit')}>
- Edit
-
-
- console.log('delete')}
- >
- Delete
-
-
-
- )
-}
-```
-
-**Context menu (long-press):**
-
-```tsx
-import * as ContextMenu from 'zeego/context-menu'
-
-function MyContextMenu() {
- return (
-
-
-
- Long press me
-
-
-
-
- console.log('copy')}>
- Copy
-
-
- console.log('paste')}>
- Paste
-
-
-
- )
-}
-```
-
-**Checkbox items:**
-
-```tsx
-import * as DropdownMenu from 'zeego/dropdown-menu'
-
-function SettingsMenu() {
- const [notifications, setNotifications] = useState(true)
-
- return (
-
-
-
- Settings
-
-
-
-
- setNotifications((prev) => !prev)}
- >
-
- Notifications
-
-
-
- )
-}
-```
-
-**Submenus:**
-
-```tsx
-import * as DropdownMenu from 'zeego/dropdown-menu'
-
-function MenuWithSubmenu() {
- return (
-
-
-
- Options
-
-
-
-
- console.log('home')}>
- Home
-
-
-
-
- More Options
-
-
-
-
- Settings
-
-
-
- Help
-
-
-
-
-
- )
-}
-```
-
-Reference: [Zeego Documentation](https://zeego.dev/components/dropdown-menu)
diff --git a/.agents/skills/vercel-react-native-skills/rules/ui-native-modals.md b/.agents/skills/vercel-react-native-skills/rules/ui-native-modals.md
deleted file mode 100644
index f560e11..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/ui-native-modals.md
+++ /dev/null
@@ -1,77 +0,0 @@
----
-title: Use Native Modals Over JS-Based Bottom Sheets
-impact: HIGH
-impactDescription: native performance, gestures, accessibility
-tags: modals, bottom-sheet, native, react-navigation
----
-
-## Use Native Modals Over JS-Based Bottom Sheets
-
-Use native `` with `presentationStyle="formSheet"` or React Navigation
-v7's native form sheet instead of JS-based bottom sheet libraries. Native modals
-have built-in gestures, accessibility, and better performance. Rely on native UI
-for low-level primitives.
-
-**Incorrect (JS-based bottom sheet):**
-
-```tsx
-import BottomSheet from 'custom-js-bottom-sheet'
-
-function MyScreen() {
- const sheetRef = useRef(null)
-
- return (
-
-
- )
-}
-```
-
-**Correct (native Modal with formSheet):**
-
-```tsx
-import { Modal, View, Text, Button } from 'react-native'
-
-function MyScreen() {
- const [visible, setVisible] = useState(false)
-
- return (
-
-
- )
-}
-```
-
-**Correct (React Navigation v7 native form sheet):**
-
-```tsx
-// In your navigator
-
-```
-
-Native modals provide swipe-to-dismiss, proper keyboard avoidance, and
-accessibility out of the box.
diff --git a/.agents/skills/vercel-react-native-skills/rules/ui-pressable.md b/.agents/skills/vercel-react-native-skills/rules/ui-pressable.md
deleted file mode 100644
index 31c3d20..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/ui-pressable.md
+++ /dev/null
@@ -1,61 +0,0 @@
----
-title: Use Pressable Instead of Touchable Components
-impact: LOW
-impactDescription: modern API, more flexible
-tags: ui, pressable, touchable, gestures
----
-
-## Use Pressable Instead of Touchable Components
-
-Never use `TouchableOpacity` or `TouchableHighlight`. Use `Pressable` from
-`react-native` or `react-native-gesture-handler` instead.
-
-**Incorrect (legacy Touchable components):**
-
-```tsx
-import { TouchableOpacity } from 'react-native'
-
-function MyButton({ onPress }: { onPress: () => void }) {
- return (
-
- Press me
-
- )
-}
-```
-
-**Correct (Pressable):**
-
-```tsx
-import { Pressable } from 'react-native'
-
-function MyButton({ onPress }: { onPress: () => void }) {
- return (
-
- Press me
-
- )
-}
-```
-
-**Correct (Pressable from gesture handler for lists):**
-
-```tsx
-import { Pressable } from 'react-native-gesture-handler'
-
-function ListItem({ onPress }: { onPress: () => void }) {
- return (
-
- Item
-
- )
-}
-```
-
-Use `react-native-gesture-handler` Pressable inside scrollable lists for better
-gesture coordination, as long as you are using the ScrollView from
-`react-native-gesture-handler` as well.
-
-**For animated press states (scale, opacity changes):** Use `GestureDetector`
-with Reanimated shared values instead of Pressable's style callback. See the
-`animation-gesture-detector-press` rule.
diff --git a/.agents/skills/vercel-react-native-skills/rules/ui-safe-area-scroll.md b/.agents/skills/vercel-react-native-skills/rules/ui-safe-area-scroll.md
deleted file mode 100644
index 79812bc..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/ui-safe-area-scroll.md
+++ /dev/null
@@ -1,65 +0,0 @@
----
-title: Use contentInsetAdjustmentBehavior for Safe Areas
-impact: MEDIUM
-impactDescription: native safe area handling, no layout shifts
-tags: safe-area, scrollview, layout
----
-
-## Use contentInsetAdjustmentBehavior for Safe Areas
-
-Use `contentInsetAdjustmentBehavior="automatic"` on the root ScrollView instead of wrapping content in SafeAreaView or manual padding. This lets iOS handle safe area insets natively with proper scroll behavior.
-
-**Incorrect (SafeAreaView wrapper):**
-
-```tsx
-import { SafeAreaView, ScrollView, View, Text } from 'react-native'
-
-function MyScreen() {
- return (
-
-
-
- Content
-
-
-
- )
-}
-```
-
-**Incorrect (manual safe area padding):**
-
-```tsx
-import { ScrollView, View, Text } from 'react-native'
-import { useSafeAreaInsets } from 'react-native-safe-area-context'
-
-function MyScreen() {
- const insets = useSafeAreaInsets()
-
- return (
-
-
- Content
-
-
- )
-}
-```
-
-**Correct (native content inset adjustment):**
-
-```tsx
-import { ScrollView, View, Text } from 'react-native'
-
-function MyScreen() {
- return (
-
-
- Content
-
-
- )
-}
-```
-
-The native approach handles dynamic safe areas (keyboard, toolbars) and allows content to scroll behind the status bar naturally.
diff --git a/.agents/skills/vercel-react-native-skills/rules/ui-scrollview-content-inset.md b/.agents/skills/vercel-react-native-skills/rules/ui-scrollview-content-inset.md
deleted file mode 100644
index bbebc3b..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/ui-scrollview-content-inset.md
+++ /dev/null
@@ -1,45 +0,0 @@
----
-title: Use contentInset for Dynamic ScrollView Spacing
-impact: LOW
-impactDescription: smoother updates, no layout recalculation
-tags: scrollview, layout, contentInset, performance
----
-
-## Use contentInset for Dynamic ScrollView Spacing
-
-When adding space to the top or bottom of a ScrollView that may change
-(keyboard, toolbars, dynamic content), use `contentInset` instead of padding.
-Changing `contentInset` doesn't trigger layout recalculation—it adjusts the
-scroll area without re-rendering content.
-
-**Incorrect (padding causes layout recalculation):**
-
-```tsx
-function Feed({ bottomOffset }: { bottomOffset: number }) {
- return (
-
- {children}
-
- )
-}
-// Changing bottomOffset triggers full layout recalculation
-```
-
-**Correct (contentInset for dynamic spacing):**
-
-```tsx
-function Feed({ bottomOffset }: { bottomOffset: number }) {
- return (
-
- {children}
-
- )
-}
-// Changing bottomOffset only adjusts scroll bounds
-```
-
-Use `scrollIndicatorInsets` alongside `contentInset` to keep the scroll
-indicator aligned. For static spacing that never changes, padding is fine.
diff --git a/.agents/skills/vercel-react-native-skills/rules/ui-styling.md b/.agents/skills/vercel-react-native-skills/rules/ui-styling.md
deleted file mode 100644
index 3908de3..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/ui-styling.md
+++ /dev/null
@@ -1,87 +0,0 @@
----
-title: Modern React Native Styling Patterns
-impact: MEDIUM
-impactDescription: consistent design, smoother borders, cleaner layouts
-tags: styling, css, layout, shadows, gradients
----
-
-## Modern React Native Styling Patterns
-
-Follow these styling patterns for cleaner, more consistent React Native code.
-
-**Always use `borderCurve: 'continuous'` with `borderRadius`:**
-
-```tsx
-// Incorrect
-{ borderRadius: 12 }
-
-// Correct – smoother iOS-style corners
-{ borderRadius: 12, borderCurve: 'continuous' }
-```
-
-**Use `gap` instead of margin for spacing between elements:**
-
-```tsx
-// Incorrect – margin on children
-
- Title
- Subtitle
-
-
-// Correct – gap on parent
-
- Title
- Subtitle
-
-```
-
-**Use `padding` for space within, `gap` for space between:**
-
-```tsx
-
- First
- Second
-
-```
-
-**Use `experimental_backgroundImage` for linear gradients:**
-
-```tsx
-// Incorrect – third-party gradient library
-
-
-// Correct – native CSS gradient syntax
-
-```
-
-**Use CSS `boxShadow` string syntax for shadows:**
-
-```tsx
-// Incorrect – legacy shadow objects or elevation
-{ shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1 }
-{ elevation: 4 }
-
-// Correct – CSS box-shadow syntax
-{ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)' }
-```
-
-**Avoid multiple font sizes – use weight and color for emphasis:**
-
-```tsx
-// Incorrect – varying font sizes for hierarchy
-Title
-Subtitle
-Caption
-
-// Correct – consistent size, vary weight and color
-Title
-Subtitle
-Caption
-```
-
-Limiting font sizes creates visual consistency. Use `fontWeight` (bold/semibold)
-and grayscale colors for hierarchy instead.
diff --git a/.biomeignore b/.biomeignore
deleted file mode 100644
index e493205..0000000
--- a/.biomeignore
+++ /dev/null
@@ -1 +0,0 @@
-app/globals.css
diff --git a/.claude/skills/vercel-react-native-skills b/.claude/skills/vercel-react-native-skills
deleted file mode 120000
index 8c98843..0000000
--- a/.claude/skills/vercel-react-native-skills
+++ /dev/null
@@ -1 +0,0 @@
-../../.agents/skills/vercel-react-native-skills
\ No newline at end of file
diff --git a/__tests__/ad-manager.test.tsx b/__tests__/ad-manager.test.tsx
index 858be76..593ce42 100644
--- a/__tests__/ad-manager.test.tsx
+++ b/__tests__/ad-manager.test.tsx
@@ -68,6 +68,19 @@ describe("AdManager", () => {
expect(screen.getByText("Vídeo da Semana")).toBeDefined();
});
+ it("hides side ads when viewed less than a week ago", () => {
+ vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID);
+ vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true");
+ localStorage.setItem(SIDE_AD_KEY, Date.now().toString());
+ render();
+
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+
+ expect(screen.queryByText("Espaço do Apoiador")).toBeNull();
+ });
+
it("hides video ad when viewed less than a week ago", () => {
vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID);
vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true");
@@ -92,6 +105,21 @@ describe("AdManager", () => {
});
});
+ it("closes the adblock modal without reloading when dismissed", async () => {
+ vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID);
+ mockFetch.mockRejectedValueOnce(new Error("blocked"));
+
+ render();
+
+ await vi.waitFor(() => {
+ expect(screen.getByText("Opa! Uma ajudinha?")).toBeDefined();
+ });
+
+ fireEvent.click(screen.getByText("Continuar com AdBlock ativo"));
+
+ expect(mockReload).not.toHaveBeenCalled();
+ });
+
it("reloads the page and closes the modal when confirming adblock is disabled", async () => {
vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID);
mockFetch.mockRejectedValueOnce(new Error("blocked"));
diff --git a/__tests__/analytics-wrapper.test.tsx b/__tests__/analytics-wrapper.test.tsx
new file mode 100644
index 0000000..6ae7b97
--- /dev/null
+++ b/__tests__/analytics-wrapper.test.tsx
@@ -0,0 +1,51 @@
+import { render } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { AnalyticsWrapper } from "@/components/organisms/analytics-wrapper";
+
+const CONSENT_KEY = "workload_cookie_consent";
+
+vi.mock("@next/third-parties/google", () => ({
+ GoogleAnalytics: ({ gaId }: { gaId: string }) => (
+
+ ),
+}));
+
+describe("AnalyticsWrapper", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("renders nothing when there is no stored consent", () => {
+ vi.stubEnv("NEXT_PUBLIC_GA_ID", "GA-TEST-ID");
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders nothing when consent is stored but telemetry is false", () => {
+ vi.stubEnv("NEXT_PUBLIC_GA_ID", "GA-TEST-ID");
+ localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: false }));
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders nothing when NEXT_PUBLIC_GA_ID is unset even with consent granted", () => {
+ vi.stubEnv("NEXT_PUBLIC_GA_ID", "");
+ localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: true }));
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders GoogleAnalytics when consent is granted and the env var is present", () => {
+ vi.stubEnv("NEXT_PUBLIC_GA_ID", "GA-TEST-ID");
+ localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: true }));
+ const { getByTestId } = render();
+ expect(getByTestId("google-analytics")).toHaveAttribute(
+ "data-ga-id",
+ "GA-TEST-ID",
+ );
+ });
+});
diff --git a/__tests__/analytics.test.ts b/__tests__/analytics.test.ts
new file mode 100644
index 0000000..c1c978e
--- /dev/null
+++ b/__tests__/analytics.test.ts
@@ -0,0 +1,71 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { safeGAEvent } from "@/lib/analytics";
+
+describe("safeGAEvent", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ (window as Record).dataLayer = undefined;
+ (window as Record).gtag = undefined;
+ });
+
+ it("does nothing when there is no window to report to", () => {
+ vi.stubGlobal("window", undefined);
+ const setIntervalSpy = vi.spyOn(global, "setInterval");
+
+ expect(() => safeGAEvent("test_event")).not.toThrow();
+ expect(setIntervalSpy).not.toHaveBeenCalled();
+
+ vi.unstubAllGlobals();
+ });
+
+ it("sends the event immediately with params when dataLayer already exists", () => {
+ (window as Record).dataLayer = [];
+ const gtag = vi.fn();
+ (window as Record).gtag = gtag;
+
+ safeGAEvent("test_event", { foo: "bar" });
+
+ expect(gtag).toHaveBeenCalledWith("event", "test_event", { foo: "bar" });
+ });
+
+ it("sends the event immediately without params when dataLayer already exists", () => {
+ (window as Record).dataLayer = [];
+ const gtag = vi.fn();
+ (window as Record).gtag = gtag;
+
+ safeGAEvent("test_event");
+
+ expect(gtag).toHaveBeenCalledWith("event", "test_event");
+ });
+
+ it("retries until dataLayer becomes available", () => {
+ const gtag = vi.fn();
+ (window as Record).gtag = gtag;
+
+ safeGAEvent("test_event");
+ expect(gtag).not.toHaveBeenCalled();
+
+ vi.advanceTimersByTime(500);
+ expect(gtag).not.toHaveBeenCalled();
+
+ (window as Record).dataLayer = [];
+ vi.advanceTimersByTime(500);
+ expect(gtag).toHaveBeenCalledWith("event", "test_event");
+ });
+
+ it("gives up and clears the interval after the max retries", () => {
+ const clearIntervalSpy = vi.spyOn(global, "clearInterval");
+ const gtag = vi.fn();
+ (window as Record).gtag = gtag;
+
+ safeGAEvent("test_event");
+ vi.advanceTimersByTime(500 * 10);
+
+ expect(gtag).not.toHaveBeenCalled();
+ expect(clearIntervalSpy).toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/button.test.tsx b/__tests__/button.test.tsx
index 3435ded..97cc421 100644
--- a/__tests__/button.test.tsx
+++ b/__tests__/button.test.tsx
@@ -1,5 +1,6 @@
import { render } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
import { Button } from "@/components/atoms/button";
describe("Button", () => {
@@ -59,4 +60,24 @@ describe("Button", () => {
const button = container.querySelector("button");
expect(button?.className).toContain("my-custom");
});
+
+ it("forwards the ref to the underlying button element", () => {
+ const ref = { current: null as HTMLButtonElement | null };
+ render();
+ expect(ref.current).toBeInstanceOf(HTMLButtonElement);
+ });
+
+ it("blocks the click handler when disabled", async () => {
+ const handleClick = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(
+ ,
+ );
+ const button = container.querySelector("button") as HTMLButtonElement;
+ await user.click(button);
+
+ expect(handleClick).not.toHaveBeenCalled();
+ });
});
diff --git a/__tests__/calculator-layout.test.tsx b/__tests__/calculator-layout.test.tsx
new file mode 100644
index 0000000..fa0f8a4
--- /dev/null
+++ b/__tests__/calculator-layout.test.tsx
@@ -0,0 +1,31 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { CalculatorLayout } from "@/components/templates/calculator-layout";
+
+describe("CalculatorLayout", () => {
+ it("renders both regions", () => {
+ render(
+ Sua Jornada}
+ aside={Painel destaque
}
+ />,
+ );
+
+ expect(
+ screen.getByRole("heading", { name: "Sua Jornada" }),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Painel destaque")).toBeInTheDocument();
+ });
+
+ it("applies the calculator accent class", () => {
+ const { container } = render(
+ Formulário
}
+ aside={Destaque
}
+ />,
+ );
+
+ expect(container.firstElementChild).toHaveClass("selection:bg-blue-500/30");
+ });
+});
diff --git a/__tests__/card.test.tsx b/__tests__/card.test.tsx
new file mode 100644
index 0000000..cfe9d71
--- /dev/null
+++ b/__tests__/card.test.tsx
@@ -0,0 +1,90 @@
+import { render } from "@testing-library/react";
+import { createRef } from "react";
+import { describe, expect, it } from "vitest";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/components/atoms/card";
+
+describe("Card", () => {
+ it("renders its children", () => {
+ const { getByText } = render(card content);
+ expect(getByText("card content")).toBeInTheDocument();
+ });
+
+ it("forwards the ref to the underlying div", () => {
+ const ref = createRef();
+ render(card);
+ expect(ref.current).toBeInstanceOf(HTMLDivElement);
+ });
+
+ it("merges custom className", () => {
+ const { container } = render(card);
+ expect(container.firstElementChild?.className).toContain("my-custom");
+ });
+});
+
+describe("CardHeader", () => {
+ it("renders its children", () => {
+ const { getByText } = render(header content);
+ expect(getByText("header content")).toBeInTheDocument();
+ });
+
+ it("forwards the ref to the underlying div", () => {
+ const ref = createRef();
+ render(header);
+ expect(ref.current).toBeInstanceOf(HTMLDivElement);
+ });
+
+ it("merges custom className", () => {
+ const { container } = render(
+ header,
+ );
+ expect(container.firstElementChild?.className).toContain("my-custom");
+ });
+});
+
+describe("CardTitle", () => {
+ it("renders its children as an h3", () => {
+ const { container, getByText } = render(
+ title content,
+ );
+ expect(getByText("title content")).toBeInTheDocument();
+ expect(container.querySelector("h3")).not.toBeNull();
+ });
+
+ it("forwards the ref to the underlying heading", () => {
+ const ref = createRef();
+ render(title);
+ expect(ref.current).toBeInstanceOf(HTMLHeadingElement);
+ });
+
+ it("merges custom className", () => {
+ const { container } = render(
+ title,
+ );
+ expect(container.querySelector("h3")?.className).toContain("my-custom");
+ });
+});
+
+describe("CardContent", () => {
+ it("renders its children", () => {
+ const { getByText } = render(content body);
+ expect(getByText("content body")).toBeInTheDocument();
+ });
+
+ it("forwards the ref to the underlying div", () => {
+ const ref = createRef();
+ render(content);
+ expect(ref.current).toBeInstanceOf(HTMLDivElement);
+ });
+
+ it("merges custom className", () => {
+ const { container } = render(
+ content,
+ );
+ expect(container.firstElementChild?.className).toContain("my-custom");
+ });
+});
diff --git a/__tests__/collapsible-panel.test.tsx b/__tests__/collapsible-panel.test.tsx
new file mode 100644
index 0000000..7d3af7f
--- /dev/null
+++ b/__tests__/collapsible-panel.test.tsx
@@ -0,0 +1,36 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { CollapsiblePanel } from "@/components/molecules/collapsible-panel";
+
+describe("CollapsiblePanel", () => {
+ it("hides its content while closed", () => {
+ render(
+
+ Conteúdo
+ ,
+ );
+
+ expect(screen.queryByText("Conteúdo")).toBeNull();
+ });
+
+ it("reveals its content while open", () => {
+ render(
+
+ Conteúdo
+ ,
+ );
+
+ expect(screen.getByText("Conteúdo")).toBeInTheDocument();
+ });
+
+ it("exposes the id so a trigger can reference it", () => {
+ render(
+
+ Conteúdo
+ ,
+ );
+
+ const panel = document.getElementById("painel");
+ expect(panel).toHaveClass("overflow-hidden", "mt-6");
+ });
+});
diff --git a/__tests__/consent.test.ts b/__tests__/consent.test.ts
new file mode 100644
index 0000000..a38e3d4
--- /dev/null
+++ b/__tests__/consent.test.ts
@@ -0,0 +1,57 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { readTelemetryConsent, writeTelemetryConsent } from "@/lib/consent";
+
+const CONSENT_KEY = "workload_cookie_consent";
+
+describe("readTelemetryConsent", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it("returns null when the key is missing", () => {
+ expect(readTelemetryConsent()).toBeNull();
+ });
+
+ it("returns true when telemetry is true", () => {
+ localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: true }));
+ expect(readTelemetryConsent()).toBe(true);
+ });
+
+ it("returns false when telemetry is false", () => {
+ localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: false }));
+ expect(readTelemetryConsent()).toBe(false);
+ });
+
+ it("returns null when the stored value is malformed JSON", () => {
+ localStorage.setItem(CONSENT_KEY, "{not-json");
+ expect(readTelemetryConsent()).toBeNull();
+ });
+
+ it("returns null when the stored value is JSON null", () => {
+ localStorage.setItem(CONSENT_KEY, JSON.stringify(null));
+ expect(readTelemetryConsent()).toBeNull();
+ });
+
+ it("returns null when the stored value is a JSON array", () => {
+ localStorage.setItem(CONSENT_KEY, JSON.stringify([1, 2, 3]));
+ expect(readTelemetryConsent()).toBeNull();
+ });
+
+ it("returns null when telemetry is not a boolean", () => {
+ localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: "yes" }));
+ expect(readTelemetryConsent()).toBeNull();
+ });
+});
+
+describe("writeTelemetryConsent", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it("writes both telemetry and a numeric timestamp", () => {
+ writeTelemetryConsent(true);
+ const stored = JSON.parse(localStorage.getItem(CONSENT_KEY) as string);
+ expect(stored.telemetry).toBe(true);
+ expect(typeof stored.timestamp).toBe("number");
+ });
+});
diff --git a/__tests__/cookie-consent.test.tsx b/__tests__/cookie-consent.test.tsx
new file mode 100644
index 0000000..b29f55c
--- /dev/null
+++ b/__tests__/cookie-consent.test.tsx
@@ -0,0 +1,200 @@
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { CookieConsent } from "@/components/molecules/cookie-consent";
+
+const CONSENT_KEY = "workload_cookie_consent";
+const BANNER_DELAY_MS = 1500;
+
+const mockReload = vi.fn();
+Object.defineProperty(window, "location", {
+ configurable: true,
+ value: { ...window.location, reload: mockReload },
+});
+
+const openSettingsFromShieldButton = () => {
+ fireEvent.click(
+ screen.getByRole("button", { name: "Configurações de Privacidade" }),
+ );
+};
+
+const getTelemetryToggleButton = () => {
+ const label = screen.getByText("Telemetria (Google Analytics)");
+ return label.parentElement?.parentElement?.querySelector("button");
+};
+
+describe("CookieConsent", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("does not render the banner before the delay elapses", () => {
+ render();
+ expect(screen.queryByText("Respeitamos sua privacidade")).toBeNull();
+ });
+
+ it("renders the banner after the delay elapses when no consent is stored", () => {
+ render();
+ act(() => {
+ vi.advanceTimersByTime(BANNER_DELAY_MS);
+ });
+ expect(screen.getByText("Respeitamos sua privacidade")).toBeDefined();
+ });
+
+ it("clears the mount timer on unmount so it never fires", () => {
+ const clearTimeoutSpy = vi.spyOn(window, "clearTimeout");
+ const { unmount } = render();
+ unmount();
+ expect(clearTimeoutSpy).toHaveBeenCalled();
+ });
+
+ it("does not show the banner and seeds the toggle as enabled when consent was stored as true", () => {
+ localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: true }));
+ render();
+ act(() => {
+ vi.advanceTimersByTime(BANNER_DELAY_MS);
+ });
+ expect(screen.queryByText("Respeitamos sua privacidade")).toBeNull();
+
+ openSettingsFromShieldButton();
+ fireEvent.click(screen.getByText("Salvar Preferências"));
+
+ expect(
+ JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry,
+ ).toBe(true);
+ expect(mockReload).toHaveBeenCalledOnce();
+ });
+
+ it("does not show the banner and seeds the toggle as disabled when consent was stored as false", () => {
+ localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: false }));
+ render();
+ act(() => {
+ vi.advanceTimersByTime(BANNER_DELAY_MS);
+ });
+ expect(screen.queryByText("Respeitamos sua privacidade")).toBeNull();
+
+ openSettingsFromShieldButton();
+ fireEvent.click(screen.getByText("Salvar Preferências"));
+
+ expect(
+ JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry,
+ ).toBe(false);
+ expect(mockReload).toHaveBeenCalledOnce();
+ });
+
+ it("persists accepting all cookies and reloads the page", () => {
+ render();
+ act(() => {
+ vi.advanceTimersByTime(BANNER_DELAY_MS);
+ });
+
+ fireEvent.click(screen.getByText("Aceitar Tudo"));
+
+ expect(
+ JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry,
+ ).toBe(true);
+ expect(mockReload).toHaveBeenCalledOnce();
+ });
+
+ it("persists refusing cookies and reloads the page", () => {
+ render();
+ act(() => {
+ vi.advanceTimersByTime(BANNER_DELAY_MS);
+ });
+
+ fireEvent.click(screen.getByText("Recusar"));
+
+ expect(
+ JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry,
+ ).toBe(false);
+ expect(mockReload).toHaveBeenCalledOnce();
+ });
+
+ it("opens the settings dialog from the banner's Configurar link", () => {
+ render();
+ act(() => {
+ vi.advanceTimersByTime(BANNER_DELAY_MS);
+ });
+
+ fireEvent.click(screen.getByText("Configurar"));
+
+ expect(screen.getByText("Privacidade")).toBeDefined();
+ });
+
+ it("closes the settings dialog from the backdrop without persisting anything", () => {
+ const { container } = render();
+ act(() => {
+ vi.advanceTimersByTime(BANNER_DELAY_MS);
+ });
+ fireEvent.click(screen.getByText("Configurar"));
+
+ const backdrop = container.querySelector('[class*="backdrop-blur-sm"]');
+ expect(backdrop).not.toBeNull();
+ fireEvent.click(backdrop as Element);
+
+ expect(localStorage.getItem(CONSENT_KEY)).toBeNull();
+ expect(mockReload).not.toHaveBeenCalled();
+ });
+
+ it("closes the settings dialog from the X button without persisting anything", () => {
+ const { container } = render();
+ act(() => {
+ vi.advanceTimersByTime(BANNER_DELAY_MS);
+ });
+ fireEvent.click(screen.getByText("Configurar"));
+
+ const closeButton = container.querySelector(
+ '[class*="right-6"][class*="top-6"]',
+ );
+ expect(closeButton).not.toBeNull();
+ fireEvent.click(closeButton as Element);
+
+ expect(localStorage.getItem(CONSENT_KEY)).toBeNull();
+ expect(mockReload).not.toHaveBeenCalled();
+ });
+
+ it("toggles telemetry off and saves the toggled value", () => {
+ render();
+ act(() => {
+ vi.advanceTimersByTime(BANNER_DELAY_MS);
+ });
+ fireEvent.click(screen.getByText("Configurar"));
+
+ const toggleButton = getTelemetryToggleButton();
+ expect(toggleButton).not.toBeUndefined();
+ fireEvent.click(toggleButton as Element);
+
+ fireEvent.click(screen.getByText("Salvar Preferências"));
+
+ expect(
+ JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry,
+ ).toBe(false);
+ expect(mockReload).toHaveBeenCalledOnce();
+ });
+
+ it("toggles telemetry back on from a stored disabled consent and saves the toggled value", () => {
+ localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: false }));
+ render();
+ act(() => {
+ vi.advanceTimersByTime(BANNER_DELAY_MS);
+ });
+
+ openSettingsFromShieldButton();
+
+ const toggleButton = getTelemetryToggleButton();
+ expect(toggleButton).not.toBeUndefined();
+ fireEvent.click(toggleButton as Element);
+
+ fireEvent.click(screen.getByText("Salvar Preferências"));
+
+ expect(
+ JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry,
+ ).toBe(true);
+ expect(mockReload).toHaveBeenCalledOnce();
+ });
+});
diff --git a/__tests__/copy-button.test.tsx b/__tests__/copy-button.test.tsx
new file mode 100644
index 0000000..f6a5eca
--- /dev/null
+++ b/__tests__/copy-button.test.tsx
@@ -0,0 +1,108 @@
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { CopyButton } from "@/components/molecules/copy-button";
+
+function setClipboard(clipboard: unknown) {
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ writable: true,
+ value: clipboard,
+ });
+}
+
+describe("CopyButton", () => {
+ afterEach(() => {
+ setClipboard(undefined);
+ vi.useRealTimers();
+ });
+
+ it("copies the value and reports success", async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ const onCopied = vi.fn();
+ const user = userEvent.setup();
+ setClipboard({ writeText });
+
+ render(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Copiar horário" }));
+
+ expect(writeText).toHaveBeenCalledWith("18:48");
+ expect(onCopied).toHaveBeenCalledOnce();
+ expect(screen.getByRole("status")).toHaveTextContent("Copiado!");
+ });
+
+ it("returns to the idle state after the confirmation delay", async () => {
+ vi.useFakeTimers();
+ setClipboard({ writeText: vi.fn().mockResolvedValue(undefined) });
+ render();
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: "Copiar horário" }));
+ });
+ expect(screen.getByRole("status")).toHaveTextContent("Copiado!");
+
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+
+ expect(screen.getByRole("status")).toBeEmptyDOMElement();
+ });
+
+ it("warns the user when the clipboard API is unavailable", async () => {
+ const user = userEvent.setup();
+ setClipboard(undefined);
+
+ render();
+ await user.click(screen.getByRole("button", { name: "Copiar horário" }));
+
+ expect(screen.getByRole("status")).toHaveTextContent(
+ "Não foi possível copiar",
+ );
+ });
+
+ it("warns the user when writing to the clipboard is rejected", async () => {
+ const onCopied = vi.fn();
+ const user = userEvent.setup();
+ setClipboard({ writeText: vi.fn().mockRejectedValue(new Error("denied")) });
+
+ render(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Copiar horário" }));
+
+ expect(screen.getByRole("status")).toHaveTextContent(
+ "Não foi possível copiar",
+ );
+ expect(onCopied).not.toHaveBeenCalled();
+ });
+
+ it("clears the failure warning after its own delay", async () => {
+ vi.useFakeTimers();
+ setClipboard(undefined);
+
+ render();
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: "Copiar horário" }));
+ });
+
+ act(() => {
+ vi.advanceTimersByTime(4000);
+ });
+
+ expect(screen.getByRole("status")).toBeEmptyDOMElement();
+ });
+
+ it("can be triggered from the keyboard", async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ const user = userEvent.setup();
+ setClipboard({ writeText });
+
+ render();
+ await user.tab();
+ await user.keyboard("{Enter}");
+
+ expect(writeText).toHaveBeenCalledWith("18:48");
+ });
+});
diff --git a/__tests__/date-time-input.test.tsx b/__tests__/date-time-input.test.tsx
new file mode 100644
index 0000000..3e54a10
--- /dev/null
+++ b/__tests__/date-time-input.test.tsx
@@ -0,0 +1,135 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { format } from "date-fns";
+import { LogIn } from "lucide-react";
+import { useState } from "react";
+import { describe, expect, it, vi } from "vitest";
+import { DateTimeInput } from "@/components/molecules/date-time-input";
+
+function InputHarness({
+ initialValue = "2026-02-01T08:00",
+ onChange,
+}: {
+ initialValue?: string;
+ onChange?: (value: string) => void;
+}) {
+ const [value, setValue] = useState(initialValue);
+
+ return (
+ {
+ setValue(next);
+ onChange?.(next);
+ }}
+ />
+ );
+}
+
+describe("DateTimeInput", () => {
+ it("labels the date field through the visible label and the time field explicitly", () => {
+ render();
+
+ expect(screen.getByLabelText("Entrada")).toHaveValue("01/02/2026");
+ expect(screen.getByLabelText("Hora para Entrada")).toHaveValue("08:00");
+ });
+
+ it("reports a new date keeping the current time", async () => {
+ const onChange = vi.fn();
+ const user = userEvent.setup();
+ render();
+
+ const dateField = screen.getByLabelText("Entrada");
+ await user.clear(dateField);
+ await user.type(dateField, "15032026");
+
+ expect(onChange).toHaveBeenCalledWith("2026-03-15T08:00");
+ });
+
+ it("reports a new time keeping the current date", async () => {
+ const onChange = vi.fn();
+ const user = userEvent.setup();
+ render();
+
+ const timeField = screen.getByLabelText("Hora para Entrada");
+ await user.clear(timeField);
+ await user.type(timeField, "0930");
+
+ expect(onChange).toHaveBeenCalledWith("2026-02-01T09:30");
+ });
+
+ it("restores the previous date when an impossible one is typed", async () => {
+ const onChange = vi.fn();
+ const user = userEvent.setup();
+ render();
+
+ const dateField = screen.getByLabelText("Entrada");
+ await user.clear(dateField);
+ await user.type(dateField, "31022026");
+ await user.tab();
+
+ expect(dateField).toHaveValue("01/02/2026");
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it("restores the previous time when an impossible one is typed", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const timeField = screen.getByLabelText("Hora para Entrada");
+ await user.clear(timeField);
+ await user.type(timeField, "2599");
+ await user.tab();
+
+ expect(timeField).toHaveValue("08:00");
+ });
+
+ it("falls back to today when there is no date yet", async () => {
+ const onChange = vi.fn();
+ const user = userEvent.setup();
+ render();
+
+ expect(screen.getByLabelText("Entrada")).toHaveValue("");
+
+ await user.type(screen.getByLabelText("Hora para Entrada"), "0715");
+
+ expect(onChange).toHaveBeenCalledWith(
+ `${format(new Date(), "yyyy-MM-dd")}T07:15`,
+ );
+ });
+
+ it("falls back to midnight when there is no time yet", async () => {
+ const onChange = vi.fn();
+ const user = userEvent.setup();
+ render();
+
+ await user.type(screen.getByLabelText("Entrada"), "10032026");
+
+ expect(onChange).toHaveBeenCalledWith("2026-03-10T00:00");
+ });
+
+ it("keeps an unrecognised date part visible instead of blanking it", () => {
+ render();
+
+ expect(screen.getByLabelText("Entrada")).toHaveValue("indefinido");
+ });
+
+ it("uses the provided id for the date field", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByLabelText("Saída Real")).toHaveAttribute(
+ "id",
+ "saida-real",
+ );
+ });
+});
diff --git a/__tests__/duration-row.test.tsx b/__tests__/duration-row.test.tsx
new file mode 100644
index 0000000..f5750e5
--- /dev/null
+++ b/__tests__/duration-row.test.tsx
@@ -0,0 +1,46 @@
+import { render, screen } from "@testing-library/react";
+import { Zap } from "lucide-react";
+import { describe, expect, it } from "vitest";
+import { DurationRow } from "@/components/molecules/duration-row";
+
+describe("DurationRow", () => {
+ it("shows hours and minutes for the given amount", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Extra 50%")).toBeInTheDocument();
+ expect(screen.getByText("2h 30m")).toBeInTheDocument();
+ });
+
+ it("shows a zeroed duration when there is nothing to report", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("0h 0m")).toBeInTheDocument();
+ });
+
+ it("rounds fractional minutes", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("0h 60m")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/extra-entry-list.test.tsx b/__tests__/extra-entry-list.test.tsx
new file mode 100644
index 0000000..6a9314b
--- /dev/null
+++ b/__tests__/extra-entry-list.test.tsx
@@ -0,0 +1,50 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { ExtraEntryList } from "@/components/molecules/extra-entry-list";
+
+describe("ExtraEntryList", () => {
+ it("renders its title, its entries and the add action", () => {
+ render(
+
+ Plano de Saúde
+ ,
+ );
+
+ expect(screen.getByText("Outros Descontos")).toBeInTheDocument();
+ expect(screen.getByText("Plano de Saúde")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Adicionar desconto" }),
+ ).toHaveTextContent("Adicionar");
+ expect(document.getElementById("extra-deductions-list")).toContainElement(
+ screen.getByText("Plano de Saúde"),
+ );
+ });
+
+ it("adds an entry when the add action is pressed", async () => {
+ const onAdd = vi.fn();
+ const user = userEvent.setup();
+ render(
+
+ {null}
+ ,
+ );
+
+ const addButton = screen.getByRole("button", { name: "Adicionar ganho" });
+ expect(addButton).toHaveClass("text-emerald-600");
+
+ await user.click(addButton);
+ expect(onAdd).toHaveBeenCalledOnce();
+ });
+});
diff --git a/__tests__/extra-entry-row.test.tsx b/__tests__/extra-entry-row.test.tsx
new file mode 100644
index 0000000..fdab87c
--- /dev/null
+++ b/__tests__/extra-entry-row.test.tsx
@@ -0,0 +1,73 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { useState } from "react";
+import { describe, expect, it, vi } from "vitest";
+import { ExtraEntryRow } from "@/components/molecules/extra-entry-row";
+
+function RowHarness({ onRemove }: { onRemove?: () => void }) {
+ const [name, setName] = useState("");
+ const [value, setValue] = useState(0);
+
+ return (
+ onRemove?.()}
+ />
+ );
+}
+
+describe("ExtraEntryRow", () => {
+ it("labels every control for assistive technology", () => {
+ render();
+
+ expect(screen.getByLabelText("Descrição do desconto")).toBeInTheDocument();
+ expect(screen.getByLabelText("Valor do desconto")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Remover desconto" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByPlaceholderText("Nome (ex: Plano de Saúde)"),
+ ).toBeInTheDocument();
+ expect(screen.getByPlaceholderText("Valor")).toBeInTheDocument();
+ });
+
+ it("keeps the typed description", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.type(
+ screen.getByLabelText("Descrição do desconto"),
+ "Plano Odonto",
+ );
+
+ expect(screen.getByLabelText("Descrição do desconto")).toHaveValue(
+ "Plano Odonto",
+ );
+ });
+
+ it("formats the typed amount as currency", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.type(screen.getByLabelText("Valor do desconto"), "5000");
+
+ expect(screen.getByLabelText("Valor do desconto")).toHaveValue("50,00");
+ });
+
+ it("asks to be removed when the remove button is pressed", async () => {
+ const onRemove = vi.fn();
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("button", { name: "Remover desconto" }));
+
+ expect(onRemove).toHaveBeenCalledOnce();
+ });
+});
diff --git a/__tests__/form-field.test.tsx b/__tests__/form-field.test.tsx
new file mode 100644
index 0000000..575fcdc
--- /dev/null
+++ b/__tests__/form-field.test.tsx
@@ -0,0 +1,32 @@
+import { render } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { FormField } from "@/components/molecules/form-field";
+
+describe("FormField", () => {
+ it("associates the label with the input by id", () => {
+ const { getByLabelText } = render(
+ ,
+ );
+ expect(getByLabelText("Hours")).toBeInTheDocument();
+ });
+
+ it("renders the labelIcon and icon", () => {
+ const { getByTestId } = render(
+ }
+ icon={}
+ />,
+ );
+ expect(getByTestId("label-icon")).toBeInTheDocument();
+ expect(getByTestId("input-icon")).toBeInTheDocument();
+ });
+
+ it("merges custom className", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstElementChild?.className).toContain("my-custom");
+ });
+});
diff --git a/__tests__/google-ad.test.tsx b/__tests__/google-ad.test.tsx
index 1d05ae9..6b81b6e 100644
--- a/__tests__/google-ad.test.tsx
+++ b/__tests__/google-ad.test.tsx
@@ -1,4 +1,5 @@
import { render } from "@testing-library/react";
+import { StrictMode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GoogleAd } from "@/components/molecules/google-ad";
@@ -50,4 +51,28 @@ describe("GoogleAd", () => {
const { container } = render();
expect(container.querySelector(".my-custom-class")).toBeTruthy();
});
+
+ it("swallows the error when pushing to adsbygoogle throws", () => {
+ vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID);
+ (window as Record).adsbygoogle = {
+ push: () => {
+ throw new Error("blocked");
+ },
+ };
+ expect(() => render()).not.toThrow();
+ (window as Record).adsbygoogle = undefined;
+ });
+
+ it("pushes the ad only once when StrictMode re-invokes the mount effect", () => {
+ vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID);
+ render(
+
+
+ ,
+ );
+ const adsbygoogle = (window as Record)
+ .adsbygoogle as Array>;
+ expect(adsbygoogle).toHaveLength(1);
+ (window as Record).adsbygoogle = undefined;
+ });
});
diff --git a/__tests__/hero-panel.test.tsx b/__tests__/hero-panel.test.tsx
new file mode 100644
index 0000000..23c2c1e
--- /dev/null
+++ b/__tests__/hero-panel.test.tsx
@@ -0,0 +1,47 @@
+import { render, screen } from "@testing-library/react";
+import { Clock } from "lucide-react";
+import { describe, expect, it } from "vitest";
+import { HeroPanel } from "@/components/molecules/hero-panel";
+
+describe("HeroPanel", () => {
+ it("renders the label and the highlighted value", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("FALTAM")).toBeInTheDocument();
+ expect(screen.getByText("01:23:45")).toBeInTheDocument();
+ });
+
+ it("renders badge, children and footer when provided", () => {
+ render(
+ 10:00:00}
+ footer={Resumo Financeiro
}
+ >
+ por minuto
+ ,
+ );
+
+ expect(screen.getByText("10:00:00")).toBeInTheDocument();
+ expect(screen.getByText("por minuto")).toBeInTheDocument();
+ expect(screen.getByText("Resumo Financeiro")).toBeInTheDocument();
+ });
+
+ it("omits the footer separator when there is no footer", () => {
+ render(
+ ,
+ );
+
+ expect(document.querySelector(".border-t")).toBeNull();
+ });
+});
diff --git a/__tests__/input.test.tsx b/__tests__/input.test.tsx
new file mode 100644
index 0000000..12dd8da
--- /dev/null
+++ b/__tests__/input.test.tsx
@@ -0,0 +1,42 @@
+import { render } from "@testing-library/react";
+import { createRef } from "react";
+import { describe, expect, it } from "vitest";
+import { Input } from "@/components/atoms/input";
+
+describe("Input", () => {
+ it("renders without an icon", () => {
+ const { container } = render();
+ expect(container.querySelector("svg")).toBeNull();
+ const input = container.querySelector("input");
+ expect(input?.className).not.toContain("pl-12");
+ });
+
+ it("renders with an icon and applies the icon padding class", () => {
+ const { container } = render(
+ } placeholder="with icon" />,
+ );
+ expect(
+ container.querySelector('[data-testid="input-icon"]'),
+ ).not.toBeNull();
+ const input = container.querySelector("input");
+ expect(input?.className).toContain("pl-12");
+ });
+
+ it("forwards the ref to the underlying input", () => {
+ const ref = createRef();
+ render();
+ expect(ref.current).toBeInstanceOf(HTMLInputElement);
+ });
+
+ it("merges custom className", () => {
+ const { container } = render();
+ expect(container.querySelector("input")?.className).toContain("my-custom");
+ });
+
+ it("passes the type prop through", () => {
+ const { container } = render();
+ expect(container.querySelector("input")?.getAttribute("type")).toBe(
+ "email",
+ );
+ });
+});
diff --git a/__tests__/journey-form.test.tsx b/__tests__/journey-form.test.tsx
new file mode 100644
index 0000000..9a0cd19
--- /dev/null
+++ b/__tests__/journey-form.test.tsx
@@ -0,0 +1,162 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { useState } from "react";
+import { describe, expect, it, vi } from "vitest";
+import { JourneyForm } from "@/components/organisms/journey-form";
+
+const DEFAULT_WORK_MINUTES = 8 * 60 + 48;
+
+function JourneyHarness({
+ onReset = vi.fn(),
+ onManualExitChange,
+}: {
+ onReset?: () => void;
+ onManualExitChange?: (manual: boolean) => void;
+}) {
+ const [workMinutes, setWorkMinutes] = useState(DEFAULT_WORK_MINUTES);
+ const [firstTierRate, setFirstTierRate] = useState(50);
+ const [extraTierRate, setExtraTierRate] = useState(100);
+ const [entry, setEntry] = useState("2026-02-02T08:00");
+ const [lunchStart, setLunchStart] = useState("2026-02-02T12:00");
+ const [lunchEnd, setLunchEnd] = useState("2026-02-02T13:00");
+ const [exitValue, setExitValue] = useState("2026-02-02T17:48");
+ const [isManualExit, setIsManualExit] = useState(false);
+
+ return (
+ {
+ setIsManualExit(manual);
+ onManualExitChange?.(manual);
+ }}
+ onReset={onReset}
+ />
+ );
+}
+
+describe("JourneyForm", () => {
+ it("renders every journey moment as a labelled field", () => {
+ render();
+
+ expect(
+ screen.getByRole("heading", { name: "Sua Jornada" }),
+ ).toBeInTheDocument();
+ expect(screen.getByLabelText("Entrada")).toBeInTheDocument();
+ expect(screen.getByLabelText("Saída Almoço")).toBeInTheDocument();
+ expect(screen.getByLabelText("Volta Almoço")).toBeInTheDocument();
+ expect(screen.getByLabelText("Saída Real")).toBeInTheDocument();
+ });
+
+ it("keeps the settings panel collapsed until requested", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const settingsToggle = screen.getByRole("button", {
+ name: "Configurações da Jornada",
+ });
+ expect(settingsToggle).toHaveAttribute("aria-expanded", "false");
+ expect(screen.queryByLabelText("Tempo de Trabalho Diário")).toBeNull();
+
+ await user.click(settingsToggle);
+
+ expect(settingsToggle).toHaveAttribute("aria-expanded", "true");
+ expect(
+ screen.getByLabelText("Tempo de Trabalho Diário"),
+ ).toBeInTheDocument();
+ });
+
+ it("shows the daily journey as a masked duration and accepts a new one", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(
+ screen.getByRole("button", { name: "Configurações da Jornada" }),
+ );
+ const journeyField = screen.getByLabelText("Tempo de Trabalho Diário");
+ expect(journeyField).toHaveValue("08:48");
+
+ await user.clear(journeyField);
+ await user.type(journeyField, "0800");
+ await user.tab();
+
+ expect(journeyField).toHaveValue("08:00");
+ });
+
+ it("restores the daily journey when an incomplete duration is left behind", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(
+ screen.getByRole("button", { name: "Configurações da Jornada" }),
+ );
+ const journeyField = screen.getByLabelText("Tempo de Trabalho Diário");
+ await user.clear(journeyField);
+ await user.type(journeyField, "9");
+ await user.tab();
+
+ expect(journeyField).toHaveValue("08:48");
+ });
+
+ it("lets both overtime rates be adjusted", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(
+ screen.getByRole("button", { name: "Configurações da Jornada" }),
+ );
+
+ const firstTierField = screen.getByLabelText("Adicional até 2h extras (%)");
+ await user.clear(firstTierField);
+ await user.type(firstTierField, "75");
+ expect(firstTierField).toHaveValue(75);
+
+ const extraTierField = screen.getByLabelText("Adicional acima de 2h (%)");
+ await user.clear(extraTierField);
+ await user.type(extraTierField, "120");
+ expect(extraTierField).toHaveValue(120);
+ });
+
+ it("switches between automatic and manual exit", async () => {
+ const onManualExitChange = vi.fn();
+ const user = userEvent.setup();
+ render();
+
+ const autoButton = screen.getByRole("button", { name: "AUTO" });
+ const manualButton = screen.getByRole("button", { name: "MANUAL" });
+ expect(autoButton).toHaveAttribute("aria-pressed", "true");
+ expect(manualButton).toHaveAttribute("aria-pressed", "false");
+
+ await user.click(manualButton);
+
+ expect(onManualExitChange).toHaveBeenCalledWith(true);
+ expect(manualButton).toHaveAttribute("aria-pressed", "true");
+
+ await user.click(autoButton);
+
+ expect(onManualExitChange).toHaveBeenLastCalledWith(false);
+ });
+
+ it("asks for a reset when the reset action is used", async () => {
+ const onReset = vi.fn();
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("button", { name: "Resetar Horários" }));
+
+ expect(onReset).toHaveBeenCalledOnce();
+ });
+});
diff --git a/__tests__/label.test.tsx b/__tests__/label.test.tsx
new file mode 100644
index 0000000..457e36c
--- /dev/null
+++ b/__tests__/label.test.tsx
@@ -0,0 +1,27 @@
+import { render } from "@testing-library/react";
+import { createRef } from "react";
+import { describe, expect, it } from "vitest";
+import { Label } from "@/components/atoms/label";
+
+describe("Label", () => {
+ it("renders its children", () => {
+ const { getByText } = render();
+ expect(getByText("label content")).toBeInTheDocument();
+ });
+
+ it("associates with a control via htmlFor", () => {
+ const { getByText } = render();
+ expect(getByText("field label")).toHaveAttribute("for", "field-id");
+ });
+
+ it("forwards the ref to the underlying label", () => {
+ const ref = createRef();
+ render();
+ expect(ref.current).toBeInstanceOf(HTMLLabelElement);
+ });
+
+ it("merges custom className", () => {
+ const { getByText } = render();
+ expect(getByText("label").className).toContain("my-custom");
+ });
+});
diff --git a/__tests__/layout.test.tsx b/__tests__/layout.test.tsx
new file mode 100644
index 0000000..d878c14
--- /dev/null
+++ b/__tests__/layout.test.tsx
@@ -0,0 +1,45 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import RootLayout, { metadata, viewport } from "@/app/layout";
+
+describe("RootLayout", () => {
+ it("renders its children", () => {
+ render(
+
+ layout child
+ ,
+ );
+ expect(screen.getByText("layout child")).toBeInTheDocument();
+ });
+});
+
+describe("metadata", () => {
+ it("exposes the expected title, description and social metadata", () => {
+ expect(metadata.title).toEqual({
+ default: "WorkLoad | Calculadora Inteligente de Horas e Salário",
+ template: "%s | WorkLoad",
+ });
+ expect(metadata.description).toBe(
+ "Calcule sua jornada de trabalho, horas extras, adicional noturno e salário CLT de forma simples, rápida e precisa.",
+ );
+ expect(metadata.alternates).toEqual({ canonical: "/" });
+ expect(metadata.openGraph?.url).toBe("https://workload.devrma.com");
+ expect(metadata.twitter).toMatchObject({ card: "summary_large_image" });
+ });
+});
+
+describe("viewport", () => {
+ it("does not disable pinch-to-zoom", () => {
+ expect(viewport).not.toHaveProperty("maximumScale");
+ expect(viewport).not.toHaveProperty("userScalable");
+ });
+
+ it("exposes the expected width, initial scale and theme colors", () => {
+ expect(viewport.width).toBe("device-width");
+ expect(viewport.initialScale).toBe(1);
+ expect(viewport.themeColor).toEqual([
+ { media: "(prefers-color-scheme: light)", color: "#ffffff" },
+ { media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
+ ]);
+ });
+});
diff --git a/__tests__/manifest.test.ts b/__tests__/manifest.test.ts
new file mode 100644
index 0000000..7e1e806
--- /dev/null
+++ b/__tests__/manifest.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import manifest from "@/app/manifest";
+
+describe("manifest", () => {
+ it("returns the expected web app manifest", () => {
+ expect(manifest()).toEqual({
+ name: "WorkLoad - Calculadora de Horas",
+ short_name: "WorkLoad",
+ description:
+ "Calcule sua jornada de trabalho de forma simples e intuitiva.",
+ start_url: "/",
+ display: "standalone",
+ background_color: "#0a0a0a",
+ theme_color: "#6366f1",
+ icons: [
+ {
+ src: "/icon-192x192.png",
+ sizes: "192x192",
+ type: "image/png",
+ },
+ {
+ src: "/icon-512x512.png",
+ sizes: "512x512",
+ type: "image/png",
+ },
+ ],
+ });
+ });
+});
diff --git a/__tests__/masked-input.test.tsx b/__tests__/masked-input.test.tsx
new file mode 100644
index 0000000..992c211
--- /dev/null
+++ b/__tests__/masked-input.test.tsx
@@ -0,0 +1,161 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { useState } from "react";
+import { describe, expect, it, vi } from "vitest";
+import { MaskedInput } from "@/components/atoms/masked-input";
+
+const TIME_GROUPS = [2, 2] as const;
+const DATE_GROUPS = [2, 2, 4] as const;
+
+const isRealTime = (masked: string) => {
+ const [hours, minutes] = masked.split(":").map(Number);
+ return hours >= 0 && hours < 24 && minutes >= 0 && minutes < 60;
+};
+
+function TimeHarness({ onCommit }: { onCommit?: (value: string) => void }) {
+ const [value, setValue] = useState("08:00");
+
+ return (
+ <>
+ {
+ setValue(committed);
+ onCommit?.(committed);
+ }}
+ />
+
+ >
+ );
+}
+
+describe("MaskedInput", () => {
+ it("inserts the separator while digits are typed", async () => {
+ const user = userEvent.setup();
+ render();
+ const input = screen.getByLabelText("Hora");
+
+ await user.clear(input);
+ await user.type(input, "0");
+ expect(input).toHaveValue("0");
+
+ await user.type(input, "8");
+ expect(input).toHaveValue("08");
+
+ await user.type(input, "4");
+ expect(input).toHaveValue("08:4");
+
+ await user.type(input, "5");
+ expect(input).toHaveValue("08:45");
+ });
+
+ it("ignores non-digit characters and extra digits", async () => {
+ const user = userEvent.setup();
+ render();
+ const input = screen.getByLabelText("Hora");
+
+ await user.clear(input);
+ await user.type(input, "1a2b3c49");
+
+ expect(input).toHaveValue("12:34");
+ });
+
+ it("commits the value as soon as the mask is complete and valid", async () => {
+ const onCommit = vi.fn();
+ const user = userEvent.setup();
+ render();
+ const input = screen.getByLabelText("Hora");
+
+ await user.clear(input);
+ await user.type(input, "1015");
+
+ expect(onCommit).toHaveBeenCalledWith("10:15");
+ });
+
+ it("never commits an invalid complete value", async () => {
+ const onCommit = vi.fn();
+ const user = userEvent.setup();
+ render();
+ const input = screen.getByLabelText("Hora");
+
+ await user.clear(input);
+ await user.type(input, "9999");
+
+ expect(input).toHaveValue("99:99");
+ expect(onCommit).not.toHaveBeenCalled();
+ });
+
+ it("restores the committed value when blurred while incomplete", async () => {
+ const user = userEvent.setup();
+ render();
+ const input = screen.getByLabelText("Hora");
+
+ await user.clear(input);
+ await user.type(input, "07");
+ await user.tab();
+
+ expect(input).toHaveValue("08:00");
+ });
+
+ it("restores the committed value when blurred while invalid", async () => {
+ const user = userEvent.setup();
+ render();
+ const input = screen.getByLabelText("Hora");
+
+ await user.clear(input);
+ await user.type(input, "2588");
+ await user.tab();
+
+ expect(input).toHaveValue("08:00");
+ });
+
+ it("keeps a valid value after blurring", async () => {
+ const user = userEvent.setup();
+ render();
+ const input = screen.getByLabelText("Hora");
+
+ await user.clear(input);
+ await user.type(input, "1830");
+ await user.tab();
+
+ expect(input).toHaveValue("18:30");
+ });
+
+ it("follows the value when it changes outside the field", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(
+ screen.getByRole("button", { name: "Definir externamente" }),
+ );
+
+ expect(screen.getByLabelText("Hora")).toHaveValue("23:59");
+ });
+
+ it("supports masks with more than one separator", async () => {
+ const onCommit = vi.fn();
+ const user = userEvent.setup();
+ render(
+ true}
+ onCommit={onCommit}
+ />,
+ );
+ const input = screen.getByLabelText("Data");
+
+ await user.type(input, "01022026");
+
+ expect(input).toHaveValue("01/02/2026");
+ expect(onCommit).toHaveBeenCalledWith("01/02/2026");
+ });
+});
diff --git a/__tests__/page.test.tsx b/__tests__/page.test.tsx
new file mode 100644
index 0000000..e266d39
--- /dev/null
+++ b/__tests__/page.test.tsx
@@ -0,0 +1,117 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderToString } from "react-dom/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import Home from "@/app/page";
+import { safeGAEvent } from "@/lib/analytics";
+
+vi.mock("@/lib/analytics", () => ({
+ safeGAEvent: vi.fn(),
+}));
+
+const themeState: { resolvedTheme: string | undefined; setTheme: () => void } =
+ {
+ resolvedTheme: undefined,
+ setTheme: vi.fn(),
+ };
+
+vi.mock("next-themes", () => ({
+ useTheme: () => themeState,
+}));
+
+vi.mock("@/components/organisms/work-calculator", () => ({
+ WorkCalculator: () => Painel da jornada
,
+}));
+
+vi.mock("@/components/organisms/salary-calculator", () => ({
+ SalaryCalculator: () => Painel do custo da hora
,
+}));
+
+describe("Home", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ themeState.resolvedTheme = undefined;
+ });
+
+ it("renders the whole shell on the server instead of a blank document", () => {
+ const markup = renderToString();
+
+ expect(markup).toContain("WorkLoad");
+ expect(markup).toContain("Jornada");
+ expect(markup).toContain("Custo da Hora");
+ expect(markup).toContain("Pular para o conteúdo principal");
+ expect(markup).toContain("--:--:--");
+ });
+
+ it("describes the application for search engines", () => {
+ const markup = renderToString();
+
+ expect(markup).toContain("application/ld+json");
+ expect(markup).toContain("WebApplication");
+ });
+
+ it("shows the live clock once the client takes over", () => {
+ render();
+
+ expect(screen.getByText(/^\d{2}:\d{2}:\d{2}$/)).toBeInTheDocument();
+ });
+
+ it("reports the session metadata on mount", () => {
+ render();
+
+ expect(safeGAEvent).toHaveBeenCalledWith(
+ "session_metadata",
+ expect.objectContaining({ viewport_width: window.innerWidth }),
+ );
+ });
+
+ it("starts on the journey view", () => {
+ render();
+
+ expect(screen.getByText("Painel da jornada")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Jornada" })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ });
+
+ it("switches to the hourly cost view and tracks it", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("button", { name: "Custo da Hora" }));
+
+ expect(safeGAEvent).toHaveBeenCalledWith("switch_tab", { tab: "salary" });
+ expect(
+ await screen.findByText("Painel do custo da hora"),
+ ).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "Jornada" }));
+
+ expect(safeGAEvent).toHaveBeenCalledWith("switch_tab", { tab: "work" });
+ });
+
+ it("offers the dark theme while the light one is active", async () => {
+ themeState.resolvedTheme = "light";
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByTitle("Alternar tema"));
+
+ expect(safeGAEvent).toHaveBeenCalledWith("toggle_theme", {
+ theme: "dark",
+ });
+ });
+
+ it("offers the light theme while the dark one is active", async () => {
+ themeState.resolvedTheme = "dark";
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByTitle("Alternar tema"));
+
+ expect(safeGAEvent).toHaveBeenCalledWith("toggle_theme", {
+ theme: "light",
+ });
+ });
+});
diff --git a/__tests__/payroll.test.ts b/__tests__/payroll.test.ts
new file mode 100644
index 0000000..20cbaf3
--- /dev/null
+++ b/__tests__/payroll.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from "vitest";
+import {
+ calculateIncomeTax,
+ calculateSocialSecurity,
+ sanitizeAmount,
+} from "@/lib/payroll";
+
+describe("sanitizeAmount", () => {
+ it("keeps positive finite amounts untouched", () => {
+ expect(sanitizeAmount(1234.56)).toBe(1234.56);
+ });
+
+ it("collapses zero, negatives and non-finite values to zero", () => {
+ expect(sanitizeAmount(0)).toBe(0);
+ expect(sanitizeAmount(-500)).toBe(0);
+ expect(sanitizeAmount(Number.NaN)).toBe(0);
+ expect(sanitizeAmount(Number.POSITIVE_INFINITY)).toBe(0);
+ });
+});
+
+describe("calculateSocialSecurity", () => {
+ it("applies the first bracket rate below the minimum wage", () => {
+ expect(calculateSocialSecurity(1000)).toBe(75);
+ });
+
+ it("matches the official contribution at every bracket boundary", () => {
+ expect(calculateSocialSecurity(1621)).toBe(121.58);
+ expect(calculateSocialSecurity(2902.84)).toBe(236.94);
+ expect(calculateSocialSecurity(4354.27)).toBe(411.11);
+ expect(calculateSocialSecurity(8475.55)).toBe(988.09);
+ });
+
+ it("caps the contribution at the ceiling", () => {
+ expect(calculateSocialSecurity(20000)).toBe(988.09);
+ expect(calculateSocialSecurity(8475.55)).toBe(
+ calculateSocialSecurity(100000),
+ );
+ });
+
+ it("charges each bracket only on the portion inside it", () => {
+ expect(calculateSocialSecurity(5000)).toBe(501.51);
+ });
+
+ it("returns zero for empty or invalid salaries", () => {
+ expect(calculateSocialSecurity(0)).toBe(0);
+ expect(calculateSocialSecurity(-1000)).toBe(0);
+ expect(calculateSocialSecurity(Number.NaN)).toBe(0);
+ });
+});
+
+describe("calculateIncomeTax", () => {
+ it("exempts salaries up to the exemption ceiling", () => {
+ expect(calculateIncomeTax(3000, calculateSocialSecurity(3000))).toBe(0);
+ expect(calculateIncomeTax(5000, calculateSocialSecurity(5000))).toBe(0);
+ });
+
+ it("clamps to zero when the reduction outgrows the tax", () => {
+ expect(calculateIncomeTax(5000.01, calculateSocialSecurity(5000.01))).toBe(
+ 0,
+ );
+ });
+
+ it("reduces the tax partially inside the transition range", () => {
+ expect(calculateIncomeTax(5200, calculateSocialSecurity(5200))).toBe(71.62);
+ });
+
+ it("phases the reduction out linearly across the transition range", () => {
+ expect(calculateIncomeTax(6000, calculateSocialSecurity(6000))).toBe(385.1);
+ });
+
+ it("stops reducing once the phase-out ceiling is reached", () => {
+ expect(calculateIncomeTax(7350, calculateSocialSecurity(7350))).toBe(
+ 884.13,
+ );
+ });
+
+ it("applies the top bracket above the phase-out ceiling", () => {
+ expect(calculateIncomeTax(10000, calculateSocialSecurity(10000))).toBe(
+ 1569.55,
+ );
+ });
+
+ it("prefers the simplified deduction when it beats the contribution", () => {
+ const contribution = calculateSocialSecurity(5500);
+ expect(contribution).toBeLessThan(607.2);
+ expect(calculateIncomeTax(5500, contribution)).toBe(
+ calculateIncomeTax(5500, 0),
+ );
+ });
+
+ it("returns zero for empty or invalid salaries", () => {
+ expect(calculateIncomeTax(0, 0)).toBe(0);
+ expect(calculateIncomeTax(-5000, 0)).toBe(0);
+ expect(calculateIncomeTax(Number.NaN, Number.NaN)).toBe(0);
+ });
+});
diff --git a/__tests__/robots.test.ts b/__tests__/robots.test.ts
new file mode 100644
index 0000000..7c08c46
--- /dev/null
+++ b/__tests__/robots.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "vitest";
+import robots from "@/app/robots";
+
+describe("robots", () => {
+ it("returns the expected robots rules", () => {
+ expect(robots()).toEqual({
+ rules: {
+ userAgent: "*",
+ allow: "/",
+ },
+ sitemap: "https://workload.devrma.com/sitemap.xml",
+ });
+ });
+});
diff --git a/__tests__/salary-calculator.test.tsx b/__tests__/salary-calculator.test.tsx
new file mode 100644
index 0000000..0ad69d3
--- /dev/null
+++ b/__tests__/salary-calculator.test.tsx
@@ -0,0 +1,98 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { SalaryCalculator } from "@/components/organisms/salary-calculator";
+
+vi.mock("@/lib/analytics", () => ({
+ safeGAEvent: vi.fn(),
+}));
+
+describe("SalaryCalculator", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it("shows the stored salary, the workload and the resulting hourly value", () => {
+ render();
+
+ expect(
+ screen.getByRole("heading", { name: "Custo da Hora" }),
+ ).toBeInTheDocument();
+ expect(screen.getByLabelText("Salário Bruto (R$)")).toHaveValue("5.000,00");
+ expect(screen.getByLabelText("Carga Horária Mensal")).toHaveValue(220);
+ expect(screen.getByText("Valor da Hora")).toBeInTheDocument();
+ expect(screen.getByText("Resumo Financeiro")).toBeInTheDocument();
+ });
+
+ it("summarises net salary, total received and total deductions", () => {
+ render();
+
+ expect(screen.getByText("Salário Líquido")).toBeInTheDocument();
+ expect(screen.getByText("Total Recebido")).toBeInTheDocument();
+ expect(screen.getByText("Total Descontos")).toBeInTheDocument();
+ });
+
+ it("recalculates the hourly value when the salary changes", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const salaryField = screen.getByLabelText("Salário Bruto (R$)");
+ await user.clear(salaryField);
+ await user.type(salaryField, "100000");
+
+ expect(salaryField).toHaveValue("1.000,00");
+ });
+
+ it("leaves the workload field empty when no hours are stored", () => {
+ localStorage.setItem("monthlyHours", "0");
+
+ render();
+
+ expect(screen.getByLabelText("Carga Horária Mensal")).toHaveValue(null);
+ });
+
+ it("accepts a new workload", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const hoursField = screen.getByLabelText("Carga Horária Mensal");
+ await user.clear(hoursField);
+ await user.type(hoursField, "200");
+
+ expect(hoursField).toHaveValue(200);
+ });
+
+ it("reveals the taxes and deductions panel on demand", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const detailsToggle = screen.getByRole("button", {
+ name: "Impostos e Descontos",
+ });
+ expect(detailsToggle).toHaveAttribute("aria-expanded", "false");
+ expect(screen.queryByLabelText("INSS (R$)")).toBeNull();
+
+ await user.click(detailsToggle);
+
+ expect(detailsToggle).toHaveAttribute("aria-expanded", "true");
+ expect(screen.getByLabelText("INSS (R$)")).toBeInTheDocument();
+ expect(screen.getByText("Outros Descontos")).toBeInTheDocument();
+ expect(screen.getByText("Ganhos Extras (Líquido)")).toBeInTheDocument();
+ });
+
+ it("adds a deduction row through the panel", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(
+ screen.getByRole("button", { name: "Impostos e Descontos" }),
+ );
+ await user.click(
+ screen.getByRole("button", { name: "Adicionar desconto" }),
+ );
+
+ expect(
+ screen.getByPlaceholderText("Nome (ex: Plano de Saúde)"),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/sitemap.test.ts b/__tests__/sitemap.test.ts
new file mode 100644
index 0000000..b625ac1
--- /dev/null
+++ b/__tests__/sitemap.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, it } from "vitest";
+import sitemap from "@/app/sitemap";
+
+describe("sitemap", () => {
+ it("returns the expected sitemap entries", () => {
+ const result = sitemap();
+ expect(result).toHaveLength(1);
+ expect(result[0]?.url).toBe("https://workload.devrma.com");
+ expect(result[0]?.changeFrequency).toBe("weekly");
+ expect(result[0]?.priority).toBe(1);
+ expect(result[0]?.lastModified).toBeInstanceOf(Date);
+ });
+});
diff --git a/__tests__/stat-box.test.tsx b/__tests__/stat-box.test.tsx
new file mode 100644
index 0000000..6f1d4f6
--- /dev/null
+++ b/__tests__/stat-box.test.tsx
@@ -0,0 +1,76 @@
+import { render } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { StatBox } from "@/components/molecules/stat-box";
+
+describe("StatBox", () => {
+ it("renders with the default variant", () => {
+ const { container, getByText } = render(
+ ,
+ );
+ expect(getByText("Total")).toBeInTheDocument();
+ expect(getByText("10h")).toBeInTheDocument();
+ expect(container.firstElementChild?.className).toContain("border-blue-100");
+ });
+
+ it("renders with the success variant", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstElementChild?.className).toContain(
+ "border-emerald-100",
+ );
+ });
+
+ it("renders with the warning variant", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstElementChild?.className).toContain(
+ "border-amber-100",
+ );
+ });
+
+ it("renders with the danger variant", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstElementChild?.className).toContain("border-red-100");
+ });
+
+ it("renders with the purple variant", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstElementChild?.className).toContain(
+ "border-purple-100",
+ );
+ });
+
+ it("renders subValue only when provided", () => {
+ const { queryByText, rerender } = render(
+ ,
+ );
+ expect(queryByText("extra info")).toBeNull();
+
+ rerender();
+ expect(queryByText("extra info")).not.toBeNull();
+ });
+
+ it("renders the icon", () => {
+ const { getByTestId } = render(
+ }
+ />,
+ );
+ expect(getByTestId("stat-icon")).toBeInTheDocument();
+ });
+
+ it("merges custom className", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstElementChild?.className).toContain("my-custom");
+ });
+});
diff --git a/__tests__/storage.test.ts b/__tests__/storage.test.ts
new file mode 100644
index 0000000..c0ed1a3
--- /dev/null
+++ b/__tests__/storage.test.ts
@@ -0,0 +1,73 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { readStoredList, readStoredNumber } from "@/lib/storage";
+
+describe("readStoredNumber", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it("returns the fallback when the key is missing", () => {
+ expect(readStoredNumber("missing-key", 42)).toBe(42);
+ });
+
+ it("returns the parsed value when it is valid", () => {
+ localStorage.setItem("valid-key", "10");
+ expect(readStoredNumber("valid-key", 0)).toBe(10);
+ });
+
+ it("returns the fallback when the value is non-numeric", () => {
+ localStorage.setItem("non-numeric-key", "not-a-number");
+ expect(readStoredNumber("non-numeric-key", 5)).toBe(5);
+ });
+
+ it("returns the fallback when the value is negative", () => {
+ localStorage.setItem("negative-key", "-1");
+ expect(readStoredNumber("negative-key", 7)).toBe(7);
+ });
+
+ it("returns zero when the stored value is zero", () => {
+ localStorage.setItem("zero-key", "0");
+ expect(readStoredNumber("zero-key", 99)).toBe(0);
+ });
+
+ it("falls back when the stored value is blank", () => {
+ localStorage.setItem("empty-key", "");
+ expect(readStoredNumber("empty-key", 3)).toBe(3);
+
+ localStorage.setItem("blank-key", " ");
+ expect(readStoredNumber("blank-key", 3)).toBe(3);
+ });
+});
+
+describe("readStoredList", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ const isString = (candidate: unknown): candidate is string =>
+ typeof candidate === "string";
+
+ it("returns an empty array when the key is missing", () => {
+ expect(readStoredList("missing-list", isString)).toEqual([]);
+ });
+
+ it("returns the parsed array when it is valid", () => {
+ localStorage.setItem("valid-list", JSON.stringify(["a", "b"]));
+ expect(readStoredList("valid-list", isString)).toEqual(["a", "b"]);
+ });
+
+ it("returns an empty array when the JSON is invalid", () => {
+ localStorage.setItem("invalid-json", "{not-json");
+ expect(readStoredList("invalid-json", isString)).toEqual([]);
+ });
+
+ it("returns an empty array when the JSON is not an array", () => {
+ localStorage.setItem("not-an-array", JSON.stringify({ a: 1 }));
+ expect(readStoredList("not-an-array", isString)).toEqual([]);
+ });
+
+ it("filters out array items that fail the predicate", () => {
+ localStorage.setItem("mixed-list", JSON.stringify(["a", 1, "b", null]));
+ expect(readStoredList("mixed-list", isString)).toEqual(["a", "b"]);
+ });
+});
diff --git a/__tests__/tax-details-panel.test.tsx b/__tests__/tax-details-panel.test.tsx
new file mode 100644
index 0000000..a74cb2e
--- /dev/null
+++ b/__tests__/tax-details-panel.test.tsx
@@ -0,0 +1,212 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { TaxDetailsPanel } from "@/components/organisms/tax-details-panel";
+import { safeGAEvent } from "@/lib/analytics";
+
+vi.mock("@/lib/analytics", () => ({
+ safeGAEvent: vi.fn(),
+}));
+
+const baseProps = {
+ manualInss: null,
+ manualIrrf: null,
+ autoInss: 500,
+ autoIrrf: 250,
+ extraDeductions: [],
+ extraGains: [],
+};
+
+describe("TaxDetailsPanel", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("suggests the calculated taxes as placeholders", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByLabelText("INSS (R$)")).toHaveAttribute(
+ "placeholder",
+ "500,00",
+ );
+ expect(screen.getByLabelText("IRRF (R$)")).toHaveAttribute(
+ "placeholder",
+ "250,00",
+ );
+ });
+
+ it("shows the manual taxes when they override the calculated ones", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByLabelText("INSS (R$)")).toHaveValue("400,00");
+ expect(screen.getByLabelText("IRRF (R$)")).toHaveValue("180,00");
+ });
+
+ it("reports a manual tax amount and clears it back to automatic", async () => {
+ const onManualInssChange = vi.fn();
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ const inssField = screen.getByLabelText("INSS (R$)");
+ expect(inssField).toHaveValue("400,00");
+
+ await user.clear(inssField);
+ expect(onManualInssChange).toHaveBeenCalledWith(null);
+
+ await user.type(inssField, "1");
+ expect(onManualInssChange).toHaveBeenLastCalledWith(4000.01);
+ });
+
+ it("reports a manual income tax amount", async () => {
+ const onManualIrrfChange = vi.fn();
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.type(screen.getByLabelText("IRRF (R$)"), "7");
+
+ expect(onManualIrrfChange).toHaveBeenLastCalledWith(0.07);
+ });
+
+ it("tracks the creation of a deduction", async () => {
+ const onAddExtra = vi.fn();
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.click(
+ screen.getByRole("button", { name: "Adicionar desconto" }),
+ );
+
+ expect(onAddExtra).toHaveBeenCalledWith("deduction");
+ expect(safeGAEvent).toHaveBeenCalledWith("add_deduction");
+ });
+
+ it("tracks the creation of a gain", async () => {
+ const onAddExtra = vi.fn();
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Adicionar ganho" }));
+
+ expect(onAddExtra).toHaveBeenCalledWith("gain");
+ expect(safeGAEvent).toHaveBeenCalledWith("add_gain");
+ });
+
+ it("edits and removes an existing deduction", async () => {
+ const onUpdateExtra = vi.fn();
+ const onRemoveExtra = vi.fn();
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.type(screen.getByLabelText("Descrição do desconto"), "V");
+ expect(onUpdateExtra).toHaveBeenCalledWith("one", "deduction", "name", "V");
+
+ await user.type(screen.getByLabelText("Valor do desconto"), "5");
+ expect(onUpdateExtra).toHaveBeenLastCalledWith(
+ "one",
+ "deduction",
+ "value",
+ 0.05,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Remover desconto" }));
+ expect(onRemoveExtra).toHaveBeenCalledWith("one", "deduction");
+ });
+
+ it("edits and removes an existing gain", async () => {
+ const onUpdateExtra = vi.fn();
+ const onRemoveExtra = vi.fn();
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.type(screen.getByLabelText("Descrição do ganho"), "V");
+ expect(onUpdateExtra).toHaveBeenCalledWith("two", "gain", "name", "V");
+
+ await user.type(screen.getByLabelText("Valor do ganho"), "5");
+ expect(onUpdateExtra).toHaveBeenLastCalledWith(
+ "two",
+ "gain",
+ "value",
+ 0.05,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Remover ganho" }));
+ expect(onRemoveExtra).toHaveBeenCalledWith("two", "gain");
+ });
+});
diff --git a/__tests__/theme-provider.test.tsx b/__tests__/theme-provider.test.tsx
new file mode 100644
index 0000000..296a2a6
--- /dev/null
+++ b/__tests__/theme-provider.test.tsx
@@ -0,0 +1,14 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { ThemeProvider } from "@/components/theme-provider";
+
+describe("ThemeProvider", () => {
+ it("renders its children through next-themes", () => {
+ render(
+
+ child content
+ ,
+ );
+ expect(screen.getByText("child content")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/use-current-time.test.ts b/__tests__/use-current-time.test.ts
new file mode 100644
index 0000000..11b5510
--- /dev/null
+++ b/__tests__/use-current-time.test.ts
@@ -0,0 +1,42 @@
+import { act, renderHook } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { useCurrentTime } from "@/hooks/use-current-time";
+
+describe("useCurrentTime", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("exposes the current date once mounted", () => {
+ const { result } = renderHook(() => useCurrentTime());
+
+ expect(result.current).toBeInstanceOf(Date);
+ });
+
+ it("advances every second", () => {
+ const { result } = renderHook(() => useCurrentTime());
+ const firstTick = result.current;
+
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ expect(Number(result.current)).toBeGreaterThan(Number(firstTick));
+ });
+
+ it("stops ticking after unmount", () => {
+ const { result, unmount } = renderHook(() => useCurrentTime());
+ const lastTick = result.current;
+
+ unmount();
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
+
+ expect(result.current).toBe(lastTick);
+ });
+});
diff --git a/__tests__/use-salary-calculator.test.ts b/__tests__/use-salary-calculator.test.ts
index 333682e..ec59366 100644
--- a/__tests__/use-salary-calculator.test.ts
+++ b/__tests__/use-salary-calculator.test.ts
@@ -1,97 +1,193 @@
import { act, renderHook } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-import {
- calculateInss,
- calculateIrrf,
- useSalaryCalculator,
-} from "../hooks/use-salary-calculator";
+import { beforeEach, describe, expect, it } from "vitest";
+import { useSalaryCalculator } from "@/hooks/use-salary-calculator";
-describe("Salary Calculator Logic", () => {
- it("calculates INSS correctly for low bracket", () => {
- // Salary <= 1518
- expect(calculateInss(1500)).toBe(112.5); // 1500 * 0.075
+describe("useSalaryCalculator", () => {
+ beforeEach(() => {
+ localStorage.clear();
});
- it("calculates INSS correctly for higher brackets", () => {
- // Salary 5000:
- // 1518 * 0.075 = 113.85
- // (2793.88 - 1518) * 0.09 = 114.83
- // (4190.83 - 2793.88) * 0.12 = 167.63
- // (5000 - 4190.83) * 0.14 = 113.28
- // Total approx: 509.59
- expect(calculateInss(5000)).toBeCloseTo(509.59, 1);
- });
+ it("starts from the default salary and monthly hours", () => {
+ const { result } = renderHook(() => useSalaryCalculator());
- it("calculates INSS correctly above ceiling", () => {
- // Ceiling is 8157.41
- const maxInss = calculateInss(8157.41);
- expect(calculateInss(10000)).toBe(maxInss);
+ expect(result.current.grossSalary).toBe(5000);
+ expect(result.current.monthlyHours).toBe(220);
});
- it("calculates IRRF correctly with deduction", () => {
- // Salary 5000, INSS 509.59 -> Base 4490.41
- // Bracket 4664.68 -> 22.5% rate, 662.77 deduction
- // IRRF = 4490.41 * 0.225 - 662.77 = 1010.34 - 662.77 = 347.57
- expect(calculateIrrf(5000, 509.59)).toBeCloseTo(347.57, 1);
+ it("derives net salary and rates from the current tax tables", () => {
+ const { result } = renderHook(() => useSalaryCalculator());
+
+ expect(result.current.autoInss).toBe(501.51);
+ expect(result.current.autoIrrf).toBe(0);
+ expect(result.current.stats.netSalary).toBe(4498.49);
+ expect(result.current.stats.hourlyRate).toBeCloseTo(20.4477, 4);
+ expect(result.current.stats.minuteRate).toBeCloseTo(0.3408, 4);
});
- it("calculates IRRF correctly below exempt limit", () => {
- // Salary 2000, INSS 160 -> Base 1840 (Exempt)
- expect(calculateIrrf(2000, 160)).toBe(0);
+ it("restores previously stored values", () => {
+ localStorage.setItem("grossSalary", "9000");
+ localStorage.setItem("monthlyHours", "180");
+ localStorage.setItem(
+ "extraGains",
+ JSON.stringify([{ id: "gain-1", name: "Vale", value: 600 }]),
+ );
+ localStorage.setItem(
+ "extraDeductions",
+ JSON.stringify([{ id: "deduction-1", name: "Plano", value: 250 }]),
+ );
+
+ const { result } = renderHook(() => useSalaryCalculator());
+
+ expect(result.current.grossSalary).toBe(9000);
+ expect(result.current.monthlyHours).toBe(180);
+ expect(result.current.stats.totalExtraGains).toBe(600);
+ expect(result.current.stats.totalExtraDeductions).toBe(250);
});
-});
-describe("useSalaryCalculator Hook", () => {
- it("initializes with default values", () => {
+ it("falls back to defaults when stored numbers are unusable", () => {
+ localStorage.setItem("grossSalary", "not-a-number");
+ localStorage.setItem("monthlyHours", "-40");
+
const { result } = renderHook(() => useSalaryCalculator());
+
expect(result.current.grossSalary).toBe(5000);
expect(result.current.monthlyHours).toBe(220);
- expect(result.current.stats.hourlyRate).toBeCloseTo(18.83, 1);
});
- it("adds and removes extra deductions", () => {
+ it("ignores stored lists that are corrupted or wrongly shaped", () => {
+ localStorage.setItem("extraGains", "{not json");
+ localStorage.setItem("extraDeductions", JSON.stringify({ nope: true }));
+
+ const { result } = renderHook(() => useSalaryCalculator());
+
+ expect(result.current.extraGains).toEqual([]);
+ expect(result.current.extraDeductions).toEqual([]);
+ });
+
+ it("drops individual stored items that fail validation", () => {
+ localStorage.setItem(
+ "extraGains",
+ JSON.stringify([
+ { id: "valid", name: "Bônus", value: 100 },
+ { id: "missing-value", name: "Quebrado" },
+ { id: 42, name: "Id errado", value: 10 },
+ { id: "nan", name: "NaN", value: Number.NaN },
+ null,
+ ]),
+ );
+
+ const { result } = renderHook(() => useSalaryCalculator());
+
+ expect(result.current.extraGains).toEqual([
+ { id: "valid", name: "Bônus", value: 100 },
+ ]);
+ });
+
+ it("does not overwrite stored values before restoring them", () => {
+ localStorage.setItem("grossSalary", "7777");
+
+ renderHook(() => useSalaryCalculator());
+
+ expect(localStorage.getItem("grossSalary")).toBe("7777");
+ });
+
+ it("persists changes made after the initial restore", () => {
const { result } = renderHook(() => useSalaryCalculator());
act(() => {
- result.current.addExtra("deduction");
+ result.current.setGrossSalary(12000);
+ result.current.setMonthlyHours(200);
});
- expect(result.current.extraDeductions.length).toBe(1);
- const id = result.current.extraDeductions[0].id;
+ expect(localStorage.getItem("grossSalary")).toBe("12000");
+ expect(localStorage.getItem("monthlyHours")).toBe("200");
+ });
+
+ it("adds, updates and removes extra deductions", () => {
+ const { result } = renderHook(() => useSalaryCalculator());
+
+ act(() => {
+ result.current.addExtra("deduction");
+ });
+ const { id } = result.current.extraDeductions[0];
act(() => {
- result.current.updateExtra(id, "deduction", "value", 100);
+ result.current.updateExtra(id, "deduction", "name", "Plano de Saúde");
+ result.current.updateExtra(id, "deduction", "value", 320);
});
- expect(result.current.extraDeductions[0].value).toBe(100);
+ expect(result.current.extraDeductions[0]).toEqual({
+ id,
+ name: "Plano de Saúde",
+ value: 320,
+ });
+ expect(result.current.stats.totalExtraDeductions).toBe(320);
act(() => {
result.current.removeExtra(id, "deduction");
});
- expect(result.current.extraDeductions.length).toBe(0);
+ expect(result.current.extraDeductions).toEqual([]);
});
- it("adds and updates extra gains", () => {
+ it("adds, updates and removes extra gains", () => {
const { result } = renderHook(() => useSalaryCalculator());
act(() => {
result.current.addExtra("gain");
});
-
- expect(result.current.extraGains.length).toBe(1);
- const id = result.current.extraGains[0].id;
+ const { id } = result.current.extraGains[0];
act(() => {
result.current.updateExtra(id, "gain", "value", 500);
});
- expect(result.current.extraGains[0].value).toBe(500);
- // Stats totalValue should increase
- expect(result.current.stats.totalValue).toBeGreaterThan(4500);
+ expect(result.current.stats.totalExtraGains).toBe(500);
+ expect(result.current.stats.totalValue).toBe(
+ result.current.stats.netSalary + 500,
+ );
+
+ act(() => {
+ result.current.removeExtra(id, "gain");
+ });
+
+ expect(result.current.extraGains).toEqual([]);
});
- it("allows manual override for INSS and IRRF", () => {
+ it("coerces unusable extra values to zero instead of poisoning the totals", () => {
+ const { result } = renderHook(() => useSalaryCalculator());
+
+ act(() => {
+ result.current.addExtra("gain");
+ });
+ const { id } = result.current.extraGains[0];
+
+ act(() => {
+ result.current.updateExtra(id, "gain", "value", "abc");
+ });
+
+ expect(result.current.extraGains[0].value).toBe(0);
+ expect(result.current.stats.totalValue).toBe(
+ result.current.stats.netSalary,
+ );
+ });
+
+ it("leaves the list untouched when updating an unknown id", () => {
+ const { result } = renderHook(() => useSalaryCalculator());
+
+ act(() => {
+ result.current.addExtra("gain");
+ });
+ const before = result.current.extraGains;
+
+ act(() => {
+ result.current.updateExtra("does-not-exist", "gain", "value", 999);
+ });
+
+ expect(result.current.extraGains).toEqual(before);
+ });
+
+ it("honours manual INSS and IRRF overrides", () => {
const { result } = renderHook(() => useSalaryCalculator());
act(() => {
@@ -103,4 +199,32 @@ describe("useSalaryCalculator Hook", () => {
expect(result.current.stats.irrf).toBe(0);
expect(result.current.stats.netSalary).toBe(5000);
});
+
+ it("recomputes the income tax from a manual INSS override", () => {
+ const { result } = renderHook(() => useSalaryCalculator());
+
+ act(() => {
+ result.current.setGrossSalary(10000);
+ });
+ const withAutoInss = result.current.autoIrrf;
+
+ act(() => {
+ result.current.setManualInss(0);
+ });
+
+ expect(result.current.autoIrrf).toBeGreaterThan(withAutoInss);
+ });
+
+ it("avoids dividing by zero when the monthly hours are cleared", () => {
+ const { result } = renderHook(() => useSalaryCalculator());
+
+ act(() => {
+ result.current.setMonthlyHours(0);
+ });
+
+ expect(result.current.stats.hourlyRate).toBe(
+ result.current.stats.totalValue,
+ );
+ expect(Number.isFinite(result.current.stats.hourlyRate)).toBe(true);
+ });
});
diff --git a/__tests__/use-work-calculator.test.ts b/__tests__/use-work-calculator.test.ts
index dd008b9..cff877e 100644
--- a/__tests__/use-work-calculator.test.ts
+++ b/__tests__/use-work-calculator.test.ts
@@ -4,126 +4,312 @@ import {
calculateSuggestedExit,
calculateWorkStats,
useWorkCalculator,
-} from "../hooks/use-work-calculator";
-
-describe("Work Calculator Logic", () => {
- const workMins = 8 * 60 + 48; // 528
- // Use a fixed Monday for testing to avoid weekend logic interference
- const mockDate = "2025-01-06"; // Jan 6, 2025 is a Monday
-
- it("calculates suggested exit correctly", () => {
- const entry = `${mockDate}T08:00`;
- const lunchStart = `${mockDate}T12:00`;
- const lunchEnd = `${mockDate}T13:00`;
- // 4 hours morning (240 mins)
- // 528 - 240 = 288 mins (4h 48m) afternoon
- // 13:00 + 4h 48m = 17:48
- const exit = calculateSuggestedExit(entry, lunchStart, lunchEnd, workMins);
- expect(exit).toBe(`${mockDate}T17:48`);
- });
-
- it("calculates regular work stats with exact exit", () => {
- const entry = `${mockDate}T08:00`;
- const lunchStart = `${mockDate}T12:00`;
- const lunchEnd = `${mockDate}T13:00`;
- const exit = `${mockDate}T17:48`;
+} from "@/hooks/use-work-calculator";
- const stats = calculateWorkStats(
+const FULL_DAY_MINUTES = 8 * 60 + 48;
+const MONDAY = "2025-01-06";
+const SATURDAY = "2025-01-11";
+
+describe("calculateSuggestedExit", () => {
+ it("pushes the remaining minutes past the end of lunch", () => {
+ expect(
+ calculateSuggestedExit(
+ `${MONDAY}T08:00`,
+ `${MONDAY}T12:00`,
+ `${MONDAY}T13:00`,
+ FULL_DAY_MINUTES,
+ ),
+ ).toBe(`${MONDAY}T17:48`);
+ });
+
+ it("returns the entry unchanged when the times are out of order", () => {
+ expect(
+ calculateSuggestedExit(
+ `${MONDAY}T14:00`,
+ `${MONDAY}T12:00`,
+ `${MONDAY}T13:00`,
+ FULL_DAY_MINUTES,
+ ),
+ ).toBe(`${MONDAY}T14:00`);
+ });
+
+ it("returns the entry unchanged when a timestamp is unparseable", () => {
+ expect(calculateSuggestedExit("not-a-date", "", "", FULL_DAY_MINUTES)).toBe(
+ "not-a-date",
+ );
+ });
+
+ it("never suggests an exit before the end of lunch", () => {
+ expect(
+ calculateSuggestedExit(
+ `${MONDAY}T08:00`,
+ `${MONDAY}T18:00`,
+ `${MONDAY}T19:00`,
+ FULL_DAY_MINUTES,
+ ),
+ ).toBe(`${MONDAY}T19:00`);
+ });
+
+ const balanceAtSuggestedExit = (
+ entry: string,
+ lunchStart: string,
+ lunchEnd: string,
+ workMinutes: number,
+ ) =>
+ calculateWorkStats(
entry,
lunchStart,
lunchEnd,
+ calculateSuggestedExit(entry, lunchStart, lunchEnd, workMinutes),
+ workMinutes,
+ ).balance;
+
+ it("lands on a zero balance for a daytime journey", () => {
+ expect(
+ balanceAtSuggestedExit(
+ `${MONDAY}T08:00`,
+ `${MONDAY}T12:00`,
+ `${MONDAY}T13:00`,
+ FULL_DAY_MINUTES,
+ ),
+ ).toBe(0);
+ });
+
+ it("credits the reduced night hour so a night journey also lands on zero", () => {
+ expect(
+ balanceAtSuggestedExit(
+ `${MONDAY}T21:00`,
+ "2025-01-07T01:00",
+ "2025-01-07T02:00",
+ FULL_DAY_MINUTES,
+ ),
+ ).toBe(0);
+ });
+
+ it("gets within a minute when the reduced night hour makes zero unreachable", () => {
+ const balance = balanceAtSuggestedExit(
+ `${MONDAY}T22:00`,
+ "2025-01-07T00:00",
+ "2025-01-07T00:30",
+ 420,
+ );
+
+ expect(Math.abs(balance)).toBeLessThanOrEqual(1);
+ });
+});
+
+describe("calculateWorkStats", () => {
+ const statsFor = (exit: string, workMinutes = FULL_DAY_MINUTES) =>
+ calculateWorkStats(
+ `${MONDAY}T08:00`,
+ `${MONDAY}T12:00`,
+ `${MONDAY}T13:00`,
exit,
- workMins,
+ workMinutes,
);
+
+ it("balances to zero on an exact day", () => {
+ const stats = statsFor(`${MONDAY}T17:48`);
+
expect(stats.balance).toBe(0);
expect(stats.totalWorked).toBe(528);
- expect(stats.overtime75).toBe(0);
- expect(stats.overtime100).toBe(0);
+ expect(stats.firstTierMinutes).toBe(0);
+ expect(stats.extraTierMinutes).toBe(0);
+ });
+
+ it("reports a negative balance when leaving early", () => {
+ const stats = statsFor(`${MONDAY}T16:48`);
+
+ expect(stats.balance).toBe(-60);
+ expect(stats.firstTierMinutes).toBe(0);
+ expect(stats.extraTierMinutes).toBe(0);
});
- it("calculates overtime", () => {
- const entry = `${mockDate}T08:00`;
- const lunchStart = `${mockDate}T12:00`;
- const lunchEnd = `${mockDate}T13:00`;
- const exit = `${mockDate}T20:00`; // 7h afternoon = 420 mins. Total = 660 mins. Overtime = 132 mins.
+ it("fills the first overtime tier before the next one", () => {
+ const stats = statsFor(`${MONDAY}T19:00`);
+
+ expect(stats.balance).toBe(72);
+ expect(stats.firstTierMinutes).toBe(72);
+ expect(stats.extraTierMinutes).toBe(0);
+ });
+
+ it("caps the first tier at two hours and spills the rest over", () => {
+ const stats = statsFor(`${MONDAY}T20:00`);
+
+ expect(stats.balance).toBe(132);
+ expect(stats.firstTierMinutes).toBe(120);
+ expect(stats.extraTierMinutes).toBe(12);
+ });
+ it("pays all weekend overtime at the higher tier", () => {
const stats = calculateWorkStats(
- entry,
- lunchStart,
- lunchEnd,
- exit,
- workMins,
+ `${SATURDAY}T08:00`,
+ `${SATURDAY}T12:00`,
+ `${SATURDAY}T13:00`,
+ `${SATURDAY}T20:00`,
+ FULL_DAY_MINUTES,
);
- expect(stats.balance).toBe(132);
- expect(stats.overtime75).toBe(120); // First 2h
- expect(stats.overtime100).toBe(12); // Remaining
- });
-
- it("calculates night shift reduction correctly", () => {
- const entry = `${mockDate}T20:00`;
- const lunchStart = `2025-01-07T00:00`; // Next day
- const lunchEnd = `2025-01-07T01:00`;
- const exit = `2025-01-07T05:00`;
-
- // Total clock time worked:
- // 20:00 to 00:00 = 4h
- // 01:00 to 05:00 = 4h
- // Total = 8h (480 mins)
- // Night hours: 22:00-00:00 (2h) + 01:00-05:00 (4h) = 6h.
- // 6h night = 6 * 60 = 360 mins.
- // Equivalent: 360 * (60 / 52.5) = 411 mins approx.
- // Bonus: 411 - 360 = 51 mins.
- // Total worked = 480 + 51 = 531 mins.
-
- const stats = calculateWorkStats(entry, lunchStart, lunchEnd, exit, 480);
+
+ expect(stats.firstTierMinutes).toBe(0);
+ expect(stats.extraTierMinutes).toBe(132);
+ });
+
+ it("converts night minutes with the reduced night hour", () => {
+ const stats = calculateWorkStats(
+ `${MONDAY}T20:00`,
+ "2025-01-07T00:00",
+ "2025-01-07T01:00",
+ "2025-01-07T05:00",
+ 480,
+ );
+
expect(stats.nightMinutes).toBe(411);
expect(stats.totalWorked).toBe(531);
expect(stats.balance).toBe(51);
});
+
+ it("excludes a lunch break taken inside the night window", () => {
+ const stats = calculateWorkStats(
+ `${MONDAY}T21:00`,
+ `${MONDAY}T23:00`,
+ "2025-01-07T00:00",
+ "2025-01-07T04:00",
+ 480,
+ );
+
+ const minutesInsideNightWindow = 360;
+ const lunchMinutesInsideNightWindow = 60;
+ const paidNightMinutes =
+ minutesInsideNightWindow - lunchMinutesInsideNightWindow;
+
+ expect(stats.nightMinutes).toBe(Math.round(paidNightMinutes * (60 / 52.5)));
+ expect(stats.nightMinutes).toBe(343);
+ });
+
+ it("counts no night minutes for a purely daytime shift", () => {
+ expect(statsFor(`${MONDAY}T17:48`).nightMinutes).toBe(0);
+ });
+
+ it("returns zeroed stats when the times are out of order", () => {
+ const stats = calculateWorkStats(
+ `${MONDAY}T08:00`,
+ `${MONDAY}T12:00`,
+ `${MONDAY}T13:00`,
+ `${MONDAY}T09:00`,
+ FULL_DAY_MINUTES,
+ );
+
+ expect(stats).toEqual({
+ balance: 0,
+ nightMinutes: 0,
+ firstTierMinutes: 0,
+ extraTierMinutes: 0,
+ totalWorked: 0,
+ });
+ });
+
+ it("returns zeroed stats when a timestamp is unparseable", () => {
+ expect(
+ calculateWorkStats("nope", "nope", "nope", "nope", FULL_DAY_MINUTES)
+ .totalWorked,
+ ).toBe(0);
+ });
});
-describe("useWorkCalculator Hook", () => {
+describe("useWorkCalculator", () => {
beforeEach(() => {
localStorage.clear();
vi.useFakeTimers();
- vi.setSystemTime(new Date("2025-01-06T10:00:00Z"));
+ vi.setSystemTime(new Date(`${MONDAY}T10:00:00`));
});
- it("initializes with default values and saves to localStorage", () => {
+ it("starts from the legal overtime defaults", () => {
const { result } = renderHook(() => useWorkCalculator());
- expect(result.current.workMinutes).toBe(528);
- expect(localStorage.getItem("workMinutes")).toBe("528");
+
+ expect(result.current.workMinutes).toBe(FULL_DAY_MINUTES);
+ expect(result.current.firstTierRate).toBe(50);
+ expect(result.current.extraTierRate).toBe(100);
});
- it("loads values from localStorage", () => {
+ it("restores stored values", () => {
localStorage.setItem("workMinutes", "480");
- localStorage.setItem("entry", "2025-01-06T09:00");
+ localStorage.setItem("firstTierRate", "75");
+ localStorage.setItem("entry", `${MONDAY}T09:00`);
const { result } = renderHook(() => useWorkCalculator());
+
expect(result.current.workMinutes).toBe(480);
- expect(result.current.entry).toBe("2025-01-06T09:00");
+ expect(result.current.firstTierRate).toBe(75);
+ expect(result.current.entry).toBe(`${MONDAY}T09:00`);
});
- it("handles manual exit override", () => {
+ it("ignores stored timestamps that are not usable", () => {
+ localStorage.setItem("entry", "08:00");
+ localStorage.setItem("lunchStart", `${MONDAY}Tnonsense`);
+
const { result } = renderHook(() => useWorkCalculator());
+ expect(result.current.entry).toBe(`${MONDAY}T08:00`);
+ expect(result.current.lunchStart).toBe(`${MONDAY}T12:00`);
+ });
+
+ it("does not overwrite stored values before restoring them", () => {
+ localStorage.setItem("workMinutes", "400");
+
+ renderHook(() => useWorkCalculator());
+
+ expect(localStorage.getItem("workMinutes")).toBe("400");
+ });
+
+ it("persists changes made after the restore", () => {
+ const { result } = renderHook(() => useWorkCalculator());
+
+ act(() => {
+ result.current.setWorkMinutes(480);
+ result.current.setFirstTierRate(75);
+ result.current.setExtraTierRate(110);
+ });
+
+ expect(localStorage.getItem("workMinutes")).toBe("480");
+ expect(localStorage.getItem("firstTierRate")).toBe("75");
+ expect(localStorage.getItem("extraTierRate")).toBe("110");
+ });
+
+ it("prefers the manual exit over the suggested one", () => {
+ const { result } = renderHook(() => useWorkCalculator());
+
+ expect(result.current.displayExit).toBe(result.current.suggestedExit);
+
act(() => {
result.current.setIsManualExit(true);
- result.current.setExitOverride("2025-01-06T18:00");
+ result.current.setExitOverride(`${MONDAY}T18:00`);
});
- expect(result.current.displayExit).toBe("2025-01-06T18:00");
+ expect(result.current.displayExit).toBe(`${MONDAY}T18:00`);
});
- it("resets to defaults", () => {
+ it("restores every default, including the overtime rates", () => {
const { result } = renderHook(() => useWorkCalculator());
act(() => {
result.current.setWorkMinutes(480);
+ result.current.setFirstTierRate(75);
+ result.current.setExtraTierRate(120);
+ result.current.setIsManualExit(true);
+ result.current.setExitOverride(`${MONDAY}T22:00`);
+ });
+
+ act(() => {
result.current.resetDefaults();
});
- expect(result.current.workMinutes).toBe(528);
+ expect(result.current.workMinutes).toBe(FULL_DAY_MINUTES);
+ expect(result.current.firstTierRate).toBe(50);
+ expect(result.current.extraTierRate).toBe(100);
expect(result.current.isManualExit).toBe(false);
+ expect(result.current.exitOverride).toBe("");
+ expect(result.current.entry).toBe(`${MONDAY}T08:00`);
+ expect(result.current.lunchStart).toBe(`${MONDAY}T12:00`);
+ expect(result.current.lunchEnd).toBe(`${MONDAY}T13:00`);
});
});
diff --git a/__tests__/utils.test.ts b/__tests__/utils.test.ts
index 9d6d541..4839e7f 100644
--- a/__tests__/utils.test.ts
+++ b/__tests__/utils.test.ts
@@ -3,7 +3,6 @@ import {
cn,
formatCurrency,
formatCurrencySimple,
- formatMinutes,
parseCurrency,
} from "@/lib/utils";
@@ -66,26 +65,8 @@ describe("parseCurrency", () => {
it("strips non-digit characters", () => {
expect(parseCurrency("R$ 1.000,00")).toBe(1000);
});
-});
-
-describe("formatMinutes", () => {
- it("formats positive minutes", () => {
- expect(formatMinutes(130)).toBe("02:10");
- });
-
- it("formats zero", () => {
- expect(formatMinutes(0)).toBe("00:00");
- });
-
- it("formats negative minutes with sign", () => {
- expect(formatMinutes(-90)).toBe("-01:30");
- });
-
- it("pads single-digit hours and minutes", () => {
- expect(formatMinutes(5)).toBe("00:05");
- });
- it("handles exact hours", () => {
- expect(formatMinutes(120)).toBe("02:00");
+ it("returns zero instead of Infinity for absurdly long input", () => {
+ expect(parseCurrency("9".repeat(400))).toBe(0);
});
});
diff --git a/__tests__/work-calculator.test.tsx b/__tests__/work-calculator.test.tsx
new file mode 100644
index 0000000..3b98af5
--- /dev/null
+++ b/__tests__/work-calculator.test.tsx
@@ -0,0 +1,262 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ calculateTimerData,
+ WorkCalculator,
+} from "@/components/organisms/work-calculator";
+import { safeGAEvent } from "@/lib/analytics";
+
+vi.mock("@/lib/analytics", () => ({
+ safeGAEvent: vi.fn(),
+}));
+
+const ENTRY = "2099-06-01T08:00";
+const LUNCH_START = "2099-06-01T12:00";
+const LUNCH_END = "2099-06-01T13:00";
+const SUGGESTED_EXIT = "2099-06-01T17:48";
+
+function storeJourney() {
+ localStorage.setItem("entry", ENTRY);
+ localStorage.setItem("lunchStart", LUNCH_START);
+ localStorage.setItem("lunchEnd", LUNCH_END);
+}
+
+function setClipboard(clipboard: unknown) {
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ writable: true,
+ value: clipboard,
+ });
+}
+
+describe("calculateTimerData", () => {
+ const baseInput = {
+ currentTime: new Date("2099-06-01T10:00:00"),
+ displayExit: SUGGESTED_EXIT,
+ entry: ENTRY,
+ workMinutes: 528,
+ isManualExit: false,
+ balanceMinutes: 0,
+ balanceSign: 0,
+ totalWorkedMinutes: 528,
+ };
+
+ it("counts down to the exit", () => {
+ const timer = calculateTimerData(baseInput);
+
+ expect(timer).toMatchObject({
+ label: "FALTAM",
+ time: "07:48:00",
+ isOvertime: false,
+ entryLabel: "08:00",
+ exitLabel: "17:48",
+ });
+ expect(timer.progress).toBeCloseTo((120 / 528) * 100);
+ });
+
+ it("counts up once the exit has passed", () => {
+ const timer = calculateTimerData({
+ ...baseInput,
+ currentTime: new Date("2099-06-01T18:48:00"),
+ });
+
+ expect(timer).toMatchObject({
+ label: "HORA EXTRA",
+ time: "01:00:00",
+ isOvertime: true,
+ });
+ expect(timer.progress).toBe(100);
+ });
+
+ it("waits for the client clock before counting", () => {
+ const timer = calculateTimerData({ ...baseInput, currentTime: null });
+
+ expect(timer).toMatchObject({
+ label: "FALTAM",
+ time: "--:--:--",
+ progress: 0,
+ });
+ });
+
+ it("shows the final balance in manual mode", () => {
+ const timer = calculateTimerData({
+ ...baseInput,
+ isManualExit: true,
+ balanceMinutes: 75,
+ balanceSign: 1,
+ });
+
+ expect(timer).toMatchObject({
+ label: "BALANÇO FINAL",
+ time: "+01:15:00",
+ isOvertime: true,
+ progress: 100,
+ });
+ });
+
+ it("shows a negative final balance in manual mode", () => {
+ const timer = calculateTimerData({
+ ...baseInput,
+ isManualExit: true,
+ balanceMinutes: -30,
+ balanceSign: -1,
+ });
+
+ expect(timer).toMatchObject({
+ label: "BALANÇO FINAL",
+ time: "-00:30:00",
+ isOvertime: false,
+ });
+ });
+
+ it("treats an exactly balanced day as on target, not overtime", () => {
+ const timer = calculateTimerData({
+ ...baseInput,
+ isManualExit: true,
+ balanceMinutes: 0,
+ balanceSign: 0,
+ });
+
+ expect(timer).toMatchObject({
+ label: "BALANÇO FINAL",
+ time: "+00:00:00",
+ isOvertime: false,
+ });
+ });
+
+ it("never lets the manual progress leave the 0-100 range", () => {
+ const overworked = calculateTimerData({
+ ...baseInput,
+ isManualExit: true,
+ totalWorkedMinutes: 900,
+ });
+ expect(overworked.progress).toBe(100);
+
+ const withoutJourney = calculateTimerData({
+ ...baseInput,
+ isManualExit: true,
+ workMinutes: 0,
+ });
+ expect(withoutJourney.progress).toBe(0);
+ });
+
+ it("never lets the countdown progress leave the 0-100 range", () => {
+ const withoutJourney = calculateTimerData({ ...baseInput, workMinutes: 0 });
+ expect(withoutJourney.progress).toBe(0);
+
+ const beforeEntry = calculateTimerData({
+ ...baseInput,
+ currentTime: new Date("2099-06-01T06:00:00"),
+ });
+ expect(beforeEntry.progress).toBe(0);
+ });
+
+ it("waits when the exit is not a real moment", () => {
+ const timer = calculateTimerData({ ...baseInput, displayExit: "" });
+
+ expect(timer).toMatchObject({
+ label: "Aguardando...",
+ time: "00:00:00",
+ progress: 0,
+ exitLabel: "--:--",
+ });
+ });
+
+ it("keeps the countdown without progress when the entry is unusable", () => {
+ const timer = calculateTimerData({ ...baseInput, entry: "invalido" });
+
+ expect(timer).toMatchObject({
+ label: "FALTAM",
+ progress: 0,
+ entryLabel: "--:--",
+ });
+ });
+});
+
+describe("WorkCalculator", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ vi.clearAllMocks();
+ storeJourney();
+ });
+
+ it("renders the journey form and the countdown side by side", () => {
+ render();
+
+ expect(
+ screen.getByRole("heading", { name: "Sua Jornada" }),
+ ).toBeInTheDocument();
+ expect(screen.getByText("FALTAM")).toBeInTheDocument();
+ expect(screen.getByText("Saída Sugerida")).toBeInTheDocument();
+ expect(screen.getAllByText("17:48").length).toBeGreaterThan(0);
+ });
+
+ it("shows the day balance and the overtime tiers", () => {
+ render();
+
+ expect(
+ screen.getByRole("heading", { name: "Balanço do Dia" }),
+ ).toBeInTheDocument();
+ expect(screen.getByText("+0h 0m")).toBeInTheDocument();
+ expect(screen.getByText("Extra 50%")).toBeInTheDocument();
+ expect(screen.getByText("Extra 100%")).toBeInTheDocument();
+ });
+
+ it("copies the exit time and tracks the event", async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ const user = userEvent.setup();
+ setClipboard({ writeText });
+ render();
+
+ await user.click(screen.getByRole("button", { name: "Copiar horário" }));
+
+ expect(writeText).toHaveBeenCalledWith("17:48");
+ expect(safeGAEvent).toHaveBeenCalledWith("copy_to_clipboard", {
+ value: "17:48",
+ });
+ setClipboard(undefined);
+ });
+
+ it("switches to the final balance when manual mode is chosen", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("button", { name: "MANUAL" }));
+
+ expect(safeGAEvent).toHaveBeenCalledWith("toggle_manual_mode", {
+ value: "manual",
+ });
+ expect(screen.getByText("BALANÇO FINAL")).toBeInTheDocument();
+ expect(screen.getAllByText("Saída Real").length).toBeGreaterThan(0);
+
+ await user.click(screen.getByRole("button", { name: "AUTO" }));
+
+ expect(safeGAEvent).toHaveBeenCalledWith("toggle_manual_mode", {
+ value: "auto",
+ });
+ });
+
+ it("turns manual as soon as the real exit is edited", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const exitTimeField = screen.getByLabelText("Hora para Saída Real");
+ await user.clear(exitTimeField);
+ await user.type(exitTimeField, "1900");
+
+ expect(screen.getByRole("button", { name: "MANUAL" })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ });
+
+ it("restores the defaults and tracks the reset", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("button", { name: "Resetar Horários" }));
+
+ expect(safeGAEvent).toHaveBeenCalledWith("reset_defaults");
+ });
+});
diff --git a/__tests__/work-summary.test.tsx b/__tests__/work-summary.test.tsx
new file mode 100644
index 0000000..8c51ed6
--- /dev/null
+++ b/__tests__/work-summary.test.tsx
@@ -0,0 +1,98 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { WorkSummary } from "@/components/organisms/work-summary";
+
+describe("WorkSummary", () => {
+ it("celebrates a positive balance", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("+2h 15m")).toBeInTheDocument();
+ expect(screen.getByText("Horas extras acumuladas")).toBeInTheDocument();
+ });
+
+ it("reports a negative balance as a debt", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("-0h 45m")).toBeInTheDocument();
+ expect(screen.getByText("Horas em débito hoje")).toBeInTheDocument();
+ });
+
+ it("names the overtime tiers after the configured rates", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Extra 75%")).toBeInTheDocument();
+ expect(screen.getByText("Extra 110%")).toBeInTheDocument();
+ expect(screen.getByText("Adic. Noturno")).toBeInTheDocument();
+ expect(screen.getByText("1h 0m")).toBeInTheDocument();
+ expect(screen.getByText("0h 30m")).toBeInTheDocument();
+ expect(screen.getByText("1h 30m")).toBeInTheDocument();
+ });
+
+ it("does not present the overtime tiers as statutory rates", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByText("Extras (CLT)")).toBeNull();
+ expect(
+ screen.getByRole("heading", { name: "Extras e Adicionais" }),
+ ).toBeInTheDocument();
+ });
+});
+
+describe("WorkSummary balance sign", () => {
+ it("reads an exactly balanced day as on target", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("+0h 0m")).toBeInTheDocument();
+ expect(screen.queryByText("Horas em débito hoje")).toBeNull();
+ });
+});
diff --git a/app/layout.tsx b/app/layout.tsx
index 9401d2a..60531a6 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -58,7 +58,6 @@ export const viewport = {
],
width: "device-width",
initialScale: 1,
- maximumScale: 1,
};
export default function RootLayout({
diff --git a/app/page.tsx b/app/page.tsx
index 8f6a8d7..60d6613 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -6,20 +6,21 @@ import { AnimatePresence, motion } from "motion/react";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { Button } from "@/components/atoms/button";
-import SalaryCalculator from "@/components/salary-calculator";
-import WorkCalculator from "@/components/work-calculator";
+import { SalaryCalculator } from "@/components/organisms/salary-calculator";
+import { WorkCalculator } from "@/components/organisms/work-calculator";
+import { useCurrentTime } from "@/hooks/use-current-time";
import { safeGAEvent } from "@/lib/analytics";
type View = "work" | "salary";
+const PLACEHOLDER_CLOCK = "--:--:--";
+
export default function Home() {
const [activeView, setActiveView] = useState("work");
- const [mounted, setMounted] = useState(false);
- const [currentTime, setCurrentTime] = useState(new Date());
+ const currentTime = useCurrentTime();
const { setTheme, resolvedTheme } = useTheme();
useEffect(() => {
- requestAnimationFrame(() => setMounted(true));
safeGAEvent("session_metadata", {
screen_width: window.screen.width,
screen_height: window.screen.height,
@@ -31,14 +32,6 @@ export default function Home() {
});
}, []);
- useEffect(() => {
- if (!mounted) return;
- const timer = setInterval(() => setCurrentTime(new Date()), 1000);
- return () => clearInterval(timer);
- }, [mounted]);
-
- if (!mounted) return null;
-
return (
<>