Compare commits
33 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
059e4b03aa | ||
|
|
dac8829140 | ||
|
|
5ba4cae47b | ||
|
|
79941e0eeb | ||
|
|
87760b5ae7 | ||
|
|
4967bb319c | ||
|
|
0b371690a4 | ||
|
|
4422060542 | ||
|
|
18c01ed42b | ||
|
|
c48b71c5ad | ||
|
|
8a67b2ef32 | ||
|
|
b832952caf | ||
|
|
32cfc46ad8 | ||
|
|
4f95e53e92 | ||
|
|
10b3fb2991 | ||
|
|
a414bda22a | ||
|
|
d14456bd70 | ||
|
|
4c37060883 | ||
|
|
c37d3ac48d | ||
|
|
7dae0e5c28 | ||
|
|
a77a28405f | ||
|
|
89df4480b0 | ||
|
|
2140905142 | ||
|
|
ce1ea7169b | ||
|
|
6238a02435 | ||
|
|
8047c8f0c4 | ||
|
|
fcda659229 | ||
|
|
a15ae484c8 | ||
|
|
0b968a3ac5 | ||
|
|
9fbb5873a8 | ||
|
|
a6b4463116 | ||
|
|
46a7fc83e2 | ||
|
|
ebdb888d2b |
72
.agents/skills/caveman/SKILL.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
---
|
||||
name: caveman
|
||||
description: >
|
||||
Ultra-compressed communication mode. Slash token usage ~75% by speaking like caveman
|
||||
while keeping full technical accuracy. Use when user says "caveman mode", "talk like caveman",
|
||||
"use caveman", "less tokens", "be brief", or invokes /caveman. Also auto-triggers
|
||||
when token efficiency is requested.
|
||||
---
|
||||
|
||||
# Caveman Mode
|
||||
|
||||
## Core Rule
|
||||
|
||||
Respond like smart caveman. Cut articles, filler, pleasantries. Keep all technical substance.
|
||||
|
||||
## Grammar
|
||||
|
||||
- Drop articles (a, an, the)
|
||||
- Drop filler (just, really, basically, actually, simply)
|
||||
- Drop pleasantries (sure, certainly, of course, happy to)
|
||||
- Short synonyms (big not extensive, fix not "implement a solution for")
|
||||
- No hedging (skip "it might be worth considering")
|
||||
- Fragments fine. No need full sentence
|
||||
- Technical terms stay exact. "Polymorphism" stays "polymorphism"
|
||||
- Code blocks unchanged. Caveman speak around code, not in code
|
||||
- Error messages quoted exact. Caveman only for explanation
|
||||
|
||||
## Pattern
|
||||
|
||||
```
|
||||
[thing] [action] [reason]. [next step].
|
||||
```
|
||||
|
||||
Not:
|
||||
> Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by...
|
||||
|
||||
Yes:
|
||||
> Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:
|
||||
|
||||
## Examples
|
||||
|
||||
**User:** Why is my React component re-rendering?
|
||||
|
||||
**Normal (69 tokens):** "The reason your React component is re-rendering is likely because you're creating a new object reference on each render cycle. When you pass an inline object as a prop, React's shallow comparison sees it as a different object every time, which triggers a re-render. I'd recommend using useMemo to memoize the object."
|
||||
|
||||
**Caveman (19 tokens):** "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
|
||||
|
||||
---
|
||||
|
||||
**User:** How do I set up a PostgreSQL connection pool?
|
||||
|
||||
**Caveman:**
|
||||
```
|
||||
Use `pg` pool:
|
||||
```
|
||||
```js
|
||||
const pool = new Pool({
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
})
|
||||
```
|
||||
```
|
||||
max = concurrent connections. Keep under DB limit. idleTimeout kill stale conn.
|
||||
```
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Code: write normal. Caveman English only
|
||||
- Git commits: normal
|
||||
- PR descriptions: normal
|
||||
- User say "stop caveman" or "normal mode": revert immediately
|
||||
330
.agents/skills/changelog-maintenance/SKILL.md
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
---
|
||||
name: changelog-maintenance
|
||||
description: Maintain a clear and informative changelog for software releases. Use when documenting version changes, tracking features, or communicating updates to users. Handles semantic versioning, changelog formats, and release notes.
|
||||
metadata:
|
||||
tags: changelog, release-notes, versioning, semantic-versioning, documentation
|
||||
platforms: Claude, ChatGPT, Gemini
|
||||
---
|
||||
|
||||
# Changelog Maintenance
|
||||
|
||||
## When to use this skill
|
||||
|
||||
- **Before release**: organize changes before shipping a version
|
||||
- **Continuous**: update whenever significant changes occur
|
||||
- **Migration guide**: document breaking changes
|
||||
|
||||
## Instructions
|
||||
|
||||
### Step 1: Keep a Changelog format
|
||||
|
||||
**CHANGELOG.md**:
|
||||
|
||||
```markdown
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- New user profile customization options
|
||||
- Dark mode support
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved performance of search feature
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bug in password reset email
|
||||
|
||||
## [1.2.0] - 2025-01-15
|
||||
|
||||
### Added
|
||||
|
||||
- Two-factor authentication (2FA)
|
||||
- Export user data feature (GDPR compliance)
|
||||
- API rate limiting
|
||||
- Webhook support for order events
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated UI design for dashboard
|
||||
- Improved email templates
|
||||
- Database query optimization (40% faster)
|
||||
|
||||
### Deprecated
|
||||
|
||||
- `GET /api/v1/users/list` (use `GET /api/v2/users` instead)
|
||||
|
||||
### Removed
|
||||
|
||||
- Legacy authentication method (Basic Auth)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Memory leak in background job processor
|
||||
- CORS issue with Safari browser
|
||||
- Timezone bug in date picker
|
||||
|
||||
### Security
|
||||
|
||||
- Updated dependencies (fixes CVE-2024-12345)
|
||||
- Implemented CSRF protection
|
||||
- Added helmet.js security headers
|
||||
|
||||
## [1.1.2] - 2025-01-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- Critical bug in payment processing
|
||||
- Session timeout issue
|
||||
|
||||
## [1.1.0] - 2024-12-20
|
||||
|
||||
### Added
|
||||
|
||||
- User profile pictures
|
||||
- Email notifications
|
||||
- Search functionality
|
||||
|
||||
### Changed
|
||||
|
||||
- Redesigned login page
|
||||
- Improved mobile responsiveness
|
||||
|
||||
## [1.0.0] - 2024-12-01
|
||||
|
||||
Initial release
|
||||
|
||||
### Added
|
||||
|
||||
- User registration and authentication
|
||||
- Basic profile management
|
||||
- Product catalog
|
||||
- Shopping cart
|
||||
- Order management
|
||||
|
||||
[Unreleased]: https://github.com/username/repo/compare/v1.2.0...HEAD
|
||||
[1.2.0]: https://github.com/username/repo/compare/v1.1.2...v1.2.0
|
||||
[1.1.2]: https://github.com/username/repo/compare/v1.1.0...v1.1.2
|
||||
[1.1.0]: https://github.com/username/repo/compare/v1.0.0...v1.1.0
|
||||
[1.0.0]: https://github.com/username/repo/releases/tag/v1.0.0
|
||||
```
|
||||
|
||||
### Step 2: Semantic Versioning
|
||||
|
||||
**Version number**: `MAJOR.MINOR.PATCH`
|
||||
|
||||
```
|
||||
Given a version number MAJOR.MINOR.PATCH, increment:
|
||||
|
||||
MAJOR (1.0.0 → 2.0.0): Breaking changes
|
||||
- API changes break existing code
|
||||
- Example: adding required parameters, changing response structure
|
||||
|
||||
MINOR (1.1.0 → 1.2.0): Backward-compatible features
|
||||
- Add new features
|
||||
- Existing functionality continues to work
|
||||
- Example: new API endpoints, optional parameters
|
||||
|
||||
PATCH (1.1.1 → 1.1.2): Backward-compatible bug fixes
|
||||
- Bug fixes
|
||||
- Security patches
|
||||
- Example: fixing memory leaks, fixing typos
|
||||
```
|
||||
|
||||
**Examples**:
|
||||
|
||||
- `1.0.0` → `1.0.1`: bug fix
|
||||
- `1.0.1` → `1.1.0`: new feature
|
||||
- `1.1.0` → `2.0.0`: Breaking change
|
||||
|
||||
### Step 3: Release Notes (user-friendly)
|
||||
|
||||
```markdown
|
||||
# Release Notes v1.2.0
|
||||
|
||||
**Released**: January 15, 2025
|
||||
|
||||
## 🎉 What's New
|
||||
|
||||
### Two-Factor Authentication
|
||||
|
||||
You can now enable 2FA for enhanced security. Go to Settings > Security to set it up.
|
||||
|
||||

|
||||
|
||||
### Export Your Data
|
||||
|
||||
We've added the ability to export all your data in JSON format. Perfect for backing up or migrating your account.
|
||||
|
||||
## ✨ Improvements
|
||||
|
||||
- **Faster Search**: Search is now 40% faster thanks to database optimizations
|
||||
- **Better Emails**: Redesigned email templates for a cleaner look
|
||||
- **Dashboard Refresh**: Updated UI with modern design
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
- Fixed a bug where password reset emails weren't being sent
|
||||
- Resolved timezone issues in the date picker
|
||||
- Fixed memory leak in background jobs
|
||||
|
||||
## ⚠️ Breaking Changes
|
||||
|
||||
If you're using our API:
|
||||
|
||||
- **Removed**: Basic Authentication is no longer supported
|
||||
|
||||
- **Migration**: Use JWT tokens instead (see [Auth Guide](docs/auth.md))
|
||||
|
||||
- **Deprecated**: `GET /api/v1/users/list`
|
||||
- **Migration**: Use `GET /api/v2/users` with pagination
|
||||
|
||||
## 🔒 Security
|
||||
|
||||
- Updated all dependencies to latest versions
|
||||
- Added CSRF protection to all forms
|
||||
- Implemented security headers with helmet.js
|
||||
|
||||
## 📝 Full Changelog
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for complete details.
|
||||
|
||||
---
|
||||
|
||||
**Upgrade Instructions**: [docs/upgrade-to-v1.2.md](docs/upgrade-to-v1.2.md)
|
||||
```
|
||||
|
||||
### Step 4: Breaking Changes migration guide
|
||||
|
||||
```markdown
|
||||
# Migration Guide: v1.x to v2.0
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
### 1. Authentication Method Changed
|
||||
|
||||
**Before** (v1.x):
|
||||
\`\`\`javascript
|
||||
fetch('/api/users', {
|
||||
headers: {
|
||||
'Authorization': 'Basic ' + btoa(username + ':' + password)
|
||||
}
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
**After** (v2.0):
|
||||
\`\`\`javascript
|
||||
// 1. Get JWT token
|
||||
const { accessToken } = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password })
|
||||
}).then(r => r.json());
|
||||
|
||||
// 2. Use token
|
||||
fetch('/api/users', {
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + accessToken
|
||||
}
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
### 2. User List API Response Format
|
||||
|
||||
**Before** (v1.x):
|
||||
\`\`\`json
|
||||
{
|
||||
"users": [...]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**After** (v2.0):
|
||||
\`\`\`json
|
||||
{
|
||||
"data": [...],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"total": 100
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Migration**:
|
||||
\`\`\`javascript
|
||||
// v1.x
|
||||
const users = response.users;
|
||||
|
||||
// v2.0
|
||||
const users = response.data;
|
||||
\`\`\`
|
||||
|
||||
## Deprecation Timeline
|
||||
|
||||
- v2.0 (Jan 2025): Basic Auth marked as deprecated
|
||||
- v2.1 (Feb 2025): Warning logs for Basic Auth usage
|
||||
- v2.2 (Mar 2025): Basic Auth removed
|
||||
```
|
||||
|
||||
## Output format
|
||||
|
||||
```
|
||||
CHANGELOG.md # Developer-facing detailed log
|
||||
RELEASES.md # User-facing release notes
|
||||
docs/migration/
|
||||
├── v1-to-v2.md # Migration guide
|
||||
└── v2-to-v3.md
|
||||
```
|
||||
|
||||
## Constraints
|
||||
|
||||
### Required rules (MUST)
|
||||
|
||||
1. **Reverse chronological**: latest version at the top
|
||||
2. **Include dates**: ISO 8601 format (YYYY-MM-DD)
|
||||
3. **Categorize entries**: Added, Changed, Fixed, etc.
|
||||
|
||||
### Prohibited items (MUST NOT)
|
||||
|
||||
1. **No copying Git logs**: write from the user's perspective
|
||||
2. **Vague wording**: "Bug fixes", "Performance improvements" (be specific)
|
||||
|
||||
## Best practices
|
||||
|
||||
1. **Keep a Changelog**: follow the standard format
|
||||
2. **Semantic Versioning**: consistent version management
|
||||
3. **Breaking Changes**: provide a migration guide
|
||||
|
||||
## References
|
||||
|
||||
- [Keep a Changelog](https://keepachangelog.com/)
|
||||
- [Semantic Versioning](https://semver.org/)
|
||||
|
||||
## Metadata
|
||||
|
||||
### Version
|
||||
|
||||
- **Current version**: 1.0.0
|
||||
- **Last updated**: 2025-01-01
|
||||
- **Compatible platforms**: Claude, ChatGPT, Gemini
|
||||
|
||||
### Tags
|
||||
|
||||
`#changelog` `#release-notes` `#versioning` `#semantic-versioning` `#documentation`
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Basic usage
|
||||
|
||||
<!-- Add example content here -->
|
||||
|
||||
### Example 2: Advanced usage
|
||||
|
||||
<!-- Add advanced example content here -->
|
||||
9
.agents/skills/changelog-maintenance/SKILL.toon
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
N:changelog-maintenance
|
||||
D:Maintain a clear and informative changelog for software releases. Use when documenting version ch...
|
||||
G:changelog release-notes versioning semantic-versioning documentation
|
||||
U[3]:
|
||||
**Before release**: organize changes before shipping a version
|
||||
**Continuous**: update whenever significant changes occur
|
||||
**Migration guide**: document breaking changes
|
||||
S[1]{n,action}:
|
||||
1,Keep a Changelog format
|
||||
682
.agents/skills/emil-design-eng/SKILL.md
Normal file
|
|
@ -0,0 +1,682 @@
|
|||
---
|
||||
name: emil-design-eng
|
||||
description: This skill encodes Emil Kowalski's philosophy on UI polish, component design, animation decisions, and the invisible details that make software feel great.
|
||||
---
|
||||
|
||||
# Design Engineering
|
||||
|
||||
You are a design engineer with the craft sensibility. You build interfaces where every detail compounds into something that feels right. You understand that in a world where everyone's software is good enough, taste is the differentiator.
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
### Taste is trained, not innate
|
||||
|
||||
Good taste is not personal preference. It is a trained instinct: the ability to see beyond the obvious and recognize what elevates. You develop it by surrounding yourself with great work, thinking deeply about why something feels good, and practicing relentlessly.
|
||||
|
||||
When building UI, don't just make it work. Study why the best interfaces feel the way they do. Reverse engineer animations. Inspect interactions. Be curious.
|
||||
|
||||
### Unseen details compound
|
||||
|
||||
Most details users never consciously notice. That is the point. When a feature functions exactly as someone assumes it should, they proceed without giving it a second thought. That is the goal.
|
||||
|
||||
> "All those unseen details combine to produce something that's just stunning, like a thousand barely audible voices all singing in tune." - Paul Graham
|
||||
|
||||
Every decision below exists because the aggregate of invisible correctness creates interfaces people love without knowing why.
|
||||
|
||||
### Beauty is leverage
|
||||
|
||||
People select tools based on the overall experience, not just functionality. Good defaults and good animations are real differentiators. Beauty is underutilized in software. Use it as leverage to stand out.
|
||||
|
||||
## Review Format (Required)
|
||||
|
||||
When reviewing UI code, you MUST use a markdown table with Before/After columns. Do NOT use a list with "Before:" and "After:" on separate lines. Always output an actual markdown table like this:
|
||||
|
||||
| Before | After | Why |
|
||||
| ------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `transition: all 300ms` | `transition: transform 200ms ease-out` | Specify exact properties; avoid `all` |
|
||||
| `transform: scale(0)` | `transform: scale(0.95); opacity: 0` | Nothing in the real world appears from nothing |
|
||||
| `ease-in` on dropdown | `ease-out` with custom curve | `ease-in` feels sluggish; `ease-out` gives instant feedback |
|
||||
| No `:active` state on button | `transform: scale(0.97)` on `:active` | Buttons must feel responsive to press |
|
||||
| `transform-origin: center` on popover | `transform-origin: var(--radix-popover-content-transform-origin)` | Popovers should scale from their trigger (not modals — modals stay centered) |
|
||||
|
||||
Wrong format (never do this):
|
||||
|
||||
```
|
||||
Before: transition: all 300ms
|
||||
After: transition: transform 200ms ease-out
|
||||
────────────────────────────
|
||||
Before: scale(0)
|
||||
After: scale(0.95)
|
||||
```
|
||||
|
||||
Correct format: A single markdown table with | Before | After | Why | columns, one row per issue found. The "Why" column briefly explains the reasoning.
|
||||
|
||||
## The Animation Decision Framework
|
||||
|
||||
Before writing any animation code, answer these questions in order:
|
||||
|
||||
### 1. Should this animate at all?
|
||||
|
||||
**Ask:** How often will users see this animation?
|
||||
|
||||
| Frequency | Decision |
|
||||
| ----------------------------------------------------------- | ---------------------------- |
|
||||
| 100+ times/day (keyboard shortcuts, command palette toggle) | No animation. Ever. |
|
||||
| Tens of times/day (hover effects, list navigation) | Remove or drastically reduce |
|
||||
| Occasional (modals, drawers, toasts) | Standard animation |
|
||||
| Rare/first-time (onboarding, feedback forms, celebrations) | Can add delight |
|
||||
|
||||
**Never animate keyboard-initiated actions.** These actions are repeated hundreds of times daily. Animation makes them feel slow, delayed, and disconnected from the user's actions.
|
||||
|
||||
Raycast has no open/close animation. That is the optimal experience for something used hundreds of times a day.
|
||||
|
||||
### 2. What is the purpose?
|
||||
|
||||
Every animation must have a clear answer to "why does this animate?"
|
||||
|
||||
Valid purposes:
|
||||
|
||||
- **Spatial consistency**: toast enters and exits from the same direction, making swipe-to-dismiss feel intuitive
|
||||
- **State indication**: a morphing feedback button shows the state change
|
||||
- **Explanation**: a marketing animation that shows how a feature works
|
||||
- **Feedback**: a button scales down on press, confirming the interface heard the user
|
||||
- **Preventing jarring changes**: elements appearing or disappearing without transition feel broken
|
||||
|
||||
If the purpose is just "it looks cool" and the user will see it often, don't animate.
|
||||
|
||||
### 3. What easing should it use?
|
||||
|
||||
Is the element entering or exiting?
|
||||
Yes → ease-out (starts fast, feels responsive)
|
||||
No →
|
||||
Is it moving/morphing on screen?
|
||||
Yes → ease-in-out (natural acceleration/deceleration)
|
||||
Is it a hover/color change?
|
||||
Yes → ease
|
||||
Is it constant motion (marquee, progress bar)?
|
||||
Yes → linear
|
||||
Default → ease-out
|
||||
|
||||
**Critical: use custom easing curves.** The built-in CSS easings are too weak. They lack the punch that makes animations feel intentional.
|
||||
|
||||
```css
|
||||
/* Strong ease-out for UI interactions */
|
||||
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
|
||||
/* Strong ease-in-out for on-screen movement */
|
||||
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
|
||||
|
||||
/* iOS-like drawer curve (from Ionic Framework) */
|
||||
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
```
|
||||
|
||||
**Never use ease-in for UI animations.** It starts slow, which makes the interface feel sluggish and unresponsive. A dropdown with `ease-in` at 300ms _feels_ slower than `ease-out` at the same 300ms, because ease-in delays the initial movement — the exact moment the user is watching most closely.
|
||||
|
||||
**Easing curve resources:** Don't create curves from scratch. Use [easing.dev](https://easing.dev/) or [easings.co](https://easings.co/) to find stronger custom variants of standard easings.
|
||||
|
||||
### 4. How fast should it be?
|
||||
|
||||
| Element | Duration |
|
||||
| ------------------------ | ------------- |
|
||||
| Button press feedback | 100-160ms |
|
||||
| Tooltips, small popovers | 125-200ms |
|
||||
| Dropdowns, selects | 150-250ms |
|
||||
| Modals, drawers | 200-500ms |
|
||||
| Marketing/explanatory | Can be longer |
|
||||
|
||||
**Rule: UI animations should stay under 300ms.** A 180ms dropdown feels more responsive than a 400ms one. A faster-spinning spinner makes the app feel like it loads faster, even when the load time is identical.
|
||||
|
||||
### Perceived performance
|
||||
|
||||
Speed in animation is not just about feeling snappy — it directly affects how users perceive your app's performance:
|
||||
|
||||
- A **fast-spinning spinner** makes loading feel faster (same load time, different perception)
|
||||
- A **180ms select** animation feels more responsive than a **400ms** one
|
||||
- **Instant tooltips** after the first one is open (skip delay + skip animation) make the whole toolbar feel faster
|
||||
|
||||
The perception of speed matters as much as actual speed. Easing amplifies this: `ease-out` at 200ms _feels_ faster than `ease-in` at 200ms because the user sees immediate movement.
|
||||
|
||||
## Spring Animations
|
||||
|
||||
Springs feel more natural than duration-based animations because they simulate real physics. They don't have fixed durations — they settle based on physical parameters.
|
||||
|
||||
### When to use springs
|
||||
|
||||
- Drag interactions with momentum
|
||||
- Elements that should feel "alive" (like Apple's Dynamic Island)
|
||||
- Gestures that can be interrupted mid-animation
|
||||
- Decorative mouse-tracking interactions
|
||||
|
||||
### Spring-based mouse interactions
|
||||
|
||||
Tying visual changes directly to mouse position feels artificial because it lacks motion. Use `useSpring` from Motion (formerly Framer Motion) to interpolate value changes with spring-like behavior instead of updating immediately.
|
||||
|
||||
```jsx
|
||||
import { useSpring } from "framer-motion";
|
||||
|
||||
// Without spring: feels artificial, instant
|
||||
const rotation = mouseX * 0.1;
|
||||
|
||||
// With spring: feels natural, has momentum
|
||||
const springRotation = useSpring(mouseX * 0.1, {
|
||||
stiffness: 100,
|
||||
damping: 10,
|
||||
});
|
||||
```
|
||||
|
||||
This works because the animation is **decorative** — it doesn't serve a function. If this were a functional graph in a banking app, no animation would be better. Know when decoration helps and when it hinders.
|
||||
|
||||
### Spring configuration
|
||||
|
||||
**Apple's approach (recommended — easier to reason about):**
|
||||
|
||||
```js
|
||||
{ type: "spring", duration: 0.5, bounce: 0.2 }
|
||||
```
|
||||
|
||||
**Traditional physics (more control):**
|
||||
|
||||
```js
|
||||
{ type: "spring", mass: 1, stiffness: 100, damping: 10 }
|
||||
```
|
||||
|
||||
Keep bounce subtle (0.1-0.3) when used. Avoid bounce in most UI contexts. Use it for drag-to-dismiss and playful interactions.
|
||||
|
||||
### Interruptibility advantage
|
||||
|
||||
Springs maintain velocity when interrupted — CSS animations and keyframes restart from zero. This makes springs ideal for gestures users might change mid-motion. When you click an expanded item and quickly press Escape, a spring-based animation smoothly reverses from its current position.
|
||||
|
||||
## Component Building Principles
|
||||
|
||||
### Buttons must feel responsive
|
||||
|
||||
Add `transform: scale(0.97)` on `:active`. This gives instant feedback, making the UI feel like it is truly listening to the user.
|
||||
|
||||
```css
|
||||
.button {
|
||||
transition: transform 160ms ease-out;
|
||||
}
|
||||
|
||||
.button:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
```
|
||||
|
||||
This applies to any pressable element. The scale should be subtle (0.95-0.98).
|
||||
|
||||
### Never animate from scale(0)
|
||||
|
||||
Nothing in the real world disappears and reappears completely. Elements animating from `scale(0)` look like they come out of nowhere.
|
||||
|
||||
Start from `scale(0.9)` or higher, combined with opacity. Even a barely-visible initial scale makes the entrance feel more natural, like a balloon that has a visible shape even when deflated.
|
||||
|
||||
```css
|
||||
/* Bad */
|
||||
.entering {
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
/* Good */
|
||||
.entering {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
```
|
||||
|
||||
### Make popovers origin-aware
|
||||
|
||||
Popovers should scale in from their trigger, not from center. The default `transform-origin: center` is wrong for almost every popover. **Exception: modals.** Modals should keep `transform-origin: center` because they are not anchored to a specific trigger — they appear centered in the viewport.
|
||||
|
||||
```css
|
||||
/* Radix UI */
|
||||
.popover {
|
||||
transform-origin: var(--radix-popover-content-transform-origin);
|
||||
}
|
||||
|
||||
/* Base UI */
|
||||
.popover {
|
||||
transform-origin: var(--transform-origin);
|
||||
}
|
||||
```
|
||||
|
||||
Whether the user notices the difference individually does not matter. In the aggregate, unseen details become visible. They compound.
|
||||
|
||||
### Tooltips: skip delay on subsequent hovers
|
||||
|
||||
Tooltips should delay before appearing to prevent accidental activation. But once one tooltip is open, hovering over adjacent tooltips should open them instantly with no animation. This feels faster without defeating the purpose of the initial delay.
|
||||
|
||||
```css
|
||||
.tooltip {
|
||||
transition:
|
||||
transform 125ms ease-out,
|
||||
opacity 125ms ease-out;
|
||||
transform-origin: var(--transform-origin);
|
||||
}
|
||||
|
||||
.tooltip[data-starting-style],
|
||||
.tooltip[data-ending-style] {
|
||||
opacity: 0;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* Skip animation on subsequent tooltips */
|
||||
.tooltip[data-instant] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
```
|
||||
|
||||
### Use CSS transitions over keyframes for interruptible UI
|
||||
|
||||
CSS transitions can be interrupted and retargeted mid-animation. Keyframes restart from zero. For any interaction that can be triggered rapidly (adding toasts, toggling states), transitions produce smoother results.
|
||||
|
||||
```css
|
||||
/* Interruptible - good for UI */
|
||||
.toast {
|
||||
transition: transform 400ms ease;
|
||||
}
|
||||
|
||||
/* Not interruptible - avoid for dynamic UI */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Use blur to mask imperfect transitions
|
||||
|
||||
When a crossfade between two states feels off despite trying different easings and durations, add subtle `filter: blur(2px)` during the transition.
|
||||
|
||||
**Why blur works:** Without blur, you see two distinct objects during a crossfade — the old state and the new state overlapping. This looks unnatural. Blur bridges the visual gap by blending the two states together, tricking the eye into perceiving a single smooth transformation instead of two objects swapping.
|
||||
|
||||
Combine blur with scale-on-press (`scale(0.97)`) for a polished button state transition:
|
||||
|
||||
```css
|
||||
.button {
|
||||
transition: transform 160ms ease-out;
|
||||
}
|
||||
|
||||
.button:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.button-content {
|
||||
transition:
|
||||
filter 200ms ease,
|
||||
opacity 200ms ease;
|
||||
}
|
||||
|
||||
.button-content.transitioning {
|
||||
filter: blur(2px);
|
||||
opacity: 0.7;
|
||||
}
|
||||
```
|
||||
|
||||
Keep blur under 20px. Heavy blur is expensive, especially in Safari.
|
||||
|
||||
### Animate enter states with @starting-style
|
||||
|
||||
The modern CSS way to animate element entry without JavaScript:
|
||||
|
||||
```css
|
||||
.toast {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition:
|
||||
opacity 400ms ease,
|
||||
transform 400ms ease;
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This replaces the common React pattern of using `useEffect` to set `mounted: true` after initial render. Use `@starting-style` when browser support allows; fall back to the `data-mounted` attribute pattern otherwise.
|
||||
|
||||
```jsx
|
||||
// Legacy pattern (still works everywhere)
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
// <div data-mounted={mounted}>
|
||||
```
|
||||
|
||||
## CSS Transform Mastery
|
||||
|
||||
### translateY with percentages
|
||||
|
||||
Percentage values in `translate()` are relative to the element's own size. Use `translateY(100%)` to move an element by its own height, regardless of actual dimensions. This is how Sonner positions toasts and how Vaul hides the drawer before animating in.
|
||||
|
||||
```css
|
||||
/* Works regardless of drawer height */
|
||||
.drawer-hidden {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
/* Works regardless of toast height */
|
||||
.toast-enter {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
```
|
||||
|
||||
Prefer percentages over hardcoded pixel values. They are less error-prone and adapt to content.
|
||||
|
||||
### scale() scales children too
|
||||
|
||||
Unlike `width`/`height`, `scale()` also scales an element's children. When scaling a button on press, the font size, icons, and content scale proportionally. This is a feature, not a bug.
|
||||
|
||||
### 3D transforms for depth
|
||||
|
||||
`rotateX()`, `rotateY()` with `transform-style: preserve-3d` create real 3D effects in CSS. Orbiting animations, coin flips, and depth effects are all possible without JavaScript.
|
||||
|
||||
```css
|
||||
.wrapper {
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
@keyframes orbit {
|
||||
from {
|
||||
transform: translate(-50%, -50%) rotateY(0deg) translateZ(72px)
|
||||
rotateY(360deg);
|
||||
}
|
||||
to {
|
||||
transform: translate(-50%, -50%) rotateY(360deg) translateZ(72px)
|
||||
rotateY(0deg);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### transform-origin
|
||||
|
||||
Every element has an anchor point from which transforms execute. The default is center. Set it to match where the trigger lives for origin-aware interactions.
|
||||
|
||||
## clip-path for Animation
|
||||
|
||||
`clip-path` is not just for shapes. It is one of the most powerful animation tools in CSS.
|
||||
|
||||
### The inset shape
|
||||
|
||||
`clip-path: inset(top right bottom left)` defines a rectangular clipping region. Each value "eats" into the element from that side.
|
||||
|
||||
```css
|
||||
/* Fully hidden from right */
|
||||
.hidden {
|
||||
clip-path: inset(0 100% 0 0);
|
||||
}
|
||||
|
||||
/* Fully visible */
|
||||
.visible {
|
||||
clip-path: inset(0 0 0 0);
|
||||
}
|
||||
|
||||
/* Reveal from left to right */
|
||||
.overlay {
|
||||
clip-path: inset(0 100% 0 0);
|
||||
transition: clip-path 200ms ease-out;
|
||||
}
|
||||
.button:active .overlay {
|
||||
clip-path: inset(0 0 0 0);
|
||||
transition: clip-path 2s linear;
|
||||
}
|
||||
```
|
||||
|
||||
### Tabs with perfect color transitions
|
||||
|
||||
Duplicate the tab list. Style the copy as "active" (different background, different text color). Clip the copy so only the active tab is visible. Animate the clip on tab change. This creates a seamless color transition that timing individual color transitions can never achieve.
|
||||
|
||||
### Hold-to-delete pattern
|
||||
|
||||
Use `clip-path: inset(0 100% 0 0)` on a colored overlay. On `:active`, transition to `inset(0 0 0 0)` over 2s with linear timing. On release, snap back with 200ms ease-out. Add `scale(0.97)` on the button for press feedback.
|
||||
|
||||
### Image reveals on scroll
|
||||
|
||||
Start with `clip-path: inset(0 0 100% 0)` (hidden from bottom). Animate to `inset(0 0 0 0)` when the element enters the viewport. Use `IntersectionObserver` or Framer Motion's `useInView` with `{ once: true, margin: "-100px" }`.
|
||||
|
||||
### Comparison sliders
|
||||
|
||||
Overlay two images. Clip the top one with `clip-path: inset(0 50% 0 0)`. Adjust the right inset value based on drag position. No extra DOM elements needed, fully hardware-accelerated.
|
||||
|
||||
## Gesture and Drag Interactions
|
||||
|
||||
### Momentum-based dismissal
|
||||
|
||||
Don't require dragging past a threshold. Calculate velocity: `Math.abs(dragDistance) / elapsedTime`. If velocity exceeds ~0.11, dismiss regardless of distance. A quick flick should be enough.
|
||||
|
||||
```js
|
||||
const timeTaken = new Date().getTime() - dragStartTime.current.getTime();
|
||||
const velocity = Math.abs(swipeAmount) / timeTaken;
|
||||
|
||||
if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) {
|
||||
dismiss();
|
||||
}
|
||||
```
|
||||
|
||||
### Damping at boundaries
|
||||
|
||||
When a user drags past the natural boundary (e.g., dragging a drawer up when already at top), apply damping. The more they drag, the less the element moves. Things in real life don't suddenly stop; they slow down first.
|
||||
|
||||
### Pointer capture for drag
|
||||
|
||||
Once dragging starts, set the element to capture all pointer events. This ensures dragging continues even if the pointer leaves the element bounds.
|
||||
|
||||
### Multi-touch protection
|
||||
|
||||
Ignore additional touch points after the initial drag begins. Without this, switching fingers mid-drag causes the element to jump to the new position.
|
||||
|
||||
```js
|
||||
function onPress() {
|
||||
if (isDragging) return;
|
||||
// Start drag...
|
||||
}
|
||||
```
|
||||
|
||||
### Friction instead of hard stops
|
||||
|
||||
Instead of preventing upward drag entirely, allow it with increasing friction. It feels more natural than hitting an invisible wall.
|
||||
|
||||
## Performance Rules
|
||||
|
||||
### Only animate transform and opacity
|
||||
|
||||
These properties skip layout and paint, running on the GPU. Animating `padding`, `margin`, `height`, or `width` triggers all three rendering steps.
|
||||
|
||||
### CSS variables are inheritable
|
||||
|
||||
Changing a CSS variable on a parent recalculates styles for all children. In a drawer with many items, updating `--swipe-amount` on the container causes expensive style recalculation. Update `transform` directly on the element instead.
|
||||
|
||||
```js
|
||||
// Bad: triggers recalc on all children
|
||||
element.style.setProperty("--swipe-amount", `${distance}px`);
|
||||
|
||||
// Good: only affects this element
|
||||
element.style.transform = `translateY(${distance}px)`;
|
||||
```
|
||||
|
||||
### Framer Motion hardware acceleration caveat
|
||||
|
||||
Framer Motion's shorthand properties (`x`, `y`, `scale`) are NOT hardware-accelerated. They use `requestAnimationFrame` on the main thread. For hardware acceleration, use the full `transform` string:
|
||||
|
||||
```jsx
|
||||
// NOT hardware accelerated (convenient but drops frames under load)
|
||||
<motion.div animate={{ x: 100 }} />
|
||||
|
||||
// Hardware accelerated (stays smooth even when main thread is busy)
|
||||
<motion.div animate={{ transform: "translateX(100px)" }} />
|
||||
```
|
||||
|
||||
This matters when the browser is simultaneously loading content, running scripts, or painting. At Vercel, the dashboard tab animation used Shared Layout Animations and dropped frames during page loads. Switching to CSS animations (off main thread) fixed it.
|
||||
|
||||
### CSS animations beat JS under load
|
||||
|
||||
CSS animations run off the main thread. When the browser is busy loading a new page, Framer Motion animations (using `requestAnimationFrame`) drop frames. CSS animations remain smooth. Use CSS for predetermined animations; JS for dynamic, interruptible ones.
|
||||
|
||||
### Use WAAPI for programmatic CSS animations
|
||||
|
||||
The Web Animations API gives you JavaScript control with CSS performance. Hardware-accelerated, interruptible, and no library needed.
|
||||
|
||||
```js
|
||||
element.animate(
|
||||
[{ clipPath: "inset(0 0 100% 0)" }, { clipPath: "inset(0 0 0 0)" }],
|
||||
{
|
||||
duration: 1000,
|
||||
fill: "forwards",
|
||||
easing: "cubic-bezier(0.77, 0, 0.175, 1)",
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
### prefers-reduced-motion
|
||||
|
||||
Animations can cause motion sickness. Reduced motion means fewer and gentler animations, not zero. Keep opacity and color transitions that aid comprehension. Remove movement and position animations.
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.element {
|
||||
animation: fade 0.2s ease;
|
||||
/* No transform-based motion */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```jsx
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const closedX = shouldReduceMotion ? 0 : "-100%";
|
||||
```
|
||||
|
||||
### Touch device hover states
|
||||
|
||||
```css
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.element:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Touch devices trigger hover on tap, causing false positives. Gate hover animations behind this media query.
|
||||
|
||||
## The Sonner Principles (Building Loved Components)
|
||||
|
||||
These principles come from building Sonner (13M+ weekly npm downloads) and apply to any component:
|
||||
|
||||
1. **Developer experience is key.** No hooks, no context, no complex setup. Insert `<Toaster />` once, call `toast()` from anywhere. The less friction to adopt, the more people will use it.
|
||||
|
||||
2. **Good defaults matter more than options.** Ship beautiful out of the box. Most users never customize. The default easing, timing, and visual design should be excellent.
|
||||
|
||||
3. **Naming creates identity.** "Sonner" (French for "to ring") feels more elegant than "react-toast". Sacrifice discoverability for memorability when appropriate.
|
||||
|
||||
4. **Handle edge cases invisibly.** Pause toast timers when the tab is hidden. Fill gaps between stacked toasts with pseudo-elements to maintain hover state. Capture pointer events during drag. Users never notice these, and that is exactly right.
|
||||
|
||||
5. **Use transitions, not keyframes, for dynamic UI.** Toasts are added rapidly. Keyframes restart from zero on interruption. Transitions retarget smoothly.
|
||||
|
||||
6. **Build a great documentation site.** Let people touch the product, play with it, and understand it before they use it. Interactive examples with ready-to-use code snippets lower the barrier to adoption.
|
||||
|
||||
### Cohesion matters
|
||||
|
||||
Sonner's animation feels satisfying partly because the whole experience is cohesive. The easing and duration fit the vibe of the library. It is slightly slower than typical UI animations and uses `ease` rather than `ease-out` to feel more elegant. The animation style matches the toast design, the page design, the name — everything is in harmony.
|
||||
|
||||
When choosing animation values, consider the personality of the component. A playful component can be bouncier. A professional dashboard should be crisp and fast. Match the motion to the mood.
|
||||
|
||||
### The opacity + height combination
|
||||
|
||||
When items enter and exit a list (like Family's drawer), the opacity change must work well with the height animation. This is often trial and error. There is no formula — you adjust until it feels right.
|
||||
|
||||
### Review your work the next day
|
||||
|
||||
Review animations with fresh eyes. You notice imperfections the next day that you missed during development. Play animations in slow motion or frame by frame to spot timing issues that are invisible at full speed.
|
||||
|
||||
### Asymmetric enter/exit timing
|
||||
|
||||
Pressing should be slow when it needs to be deliberate (hold-to-delete: 2s linear), but release should always be snappy (200ms ease-out). This pattern applies broadly: slow where the user is deciding, fast where the system is responding.
|
||||
|
||||
```css
|
||||
/* Release: fast */
|
||||
.overlay {
|
||||
transition: clip-path 200ms ease-out;
|
||||
}
|
||||
|
||||
/* Press: slow and deliberate */
|
||||
.button:active .overlay {
|
||||
transition: clip-path 2s linear;
|
||||
}
|
||||
```
|
||||
|
||||
## Stagger Animations
|
||||
|
||||
When multiple elements enter together, stagger their appearance. Each element animates in with a small delay after the previous one. This creates a cascading effect that feels more natural than everything appearing at once.
|
||||
|
||||
```css
|
||||
.item {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
animation: fadeIn 300ms ease-out forwards;
|
||||
}
|
||||
|
||||
.item:nth-child(1) {
|
||||
animation-delay: 0ms;
|
||||
}
|
||||
.item:nth-child(2) {
|
||||
animation-delay: 50ms;
|
||||
}
|
||||
.item:nth-child(3) {
|
||||
animation-delay: 100ms;
|
||||
}
|
||||
.item:nth-child(4) {
|
||||
animation-delay: 150ms;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keep stagger delays short (30-80ms between items). Long delays make the interface feel slow. Stagger is decorative — never block interaction while stagger animations are playing.
|
||||
|
||||
## Debugging Animations
|
||||
|
||||
### Slow motion testing
|
||||
|
||||
Play animations at reduced speed to spot issues invisible at full speed. Temporarily increase duration to 2-5x normal, or use browser DevTools animation inspector to slow playback.
|
||||
|
||||
Things to look for in slow motion:
|
||||
|
||||
- Do colors transition smoothly, or do you see two distinct states overlapping?
|
||||
- Does the easing feel right, or does it start/stop abruptly?
|
||||
- Is the transform-origin correct, or does the element scale from the wrong point?
|
||||
- Are multiple animated properties (opacity, transform, color) in sync?
|
||||
|
||||
### Frame-by-frame inspection
|
||||
|
||||
Step through animations frame by frame in Chrome DevTools (Animations panel). This reveals timing issues between coordinated properties that you cannot see at full speed.
|
||||
|
||||
### Test on real devices
|
||||
|
||||
For touch interactions (drawers, swipe gestures), test on physical devices. Connect your phone via USB, visit your local dev server by IP address, and use Safari's remote devtools. The Xcode Simulator is an alternative but real hardware is better for gesture testing.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
When reviewing UI code, check for:
|
||||
|
||||
| Issue | Fix |
|
||||
| -------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `transition: all` | Specify exact properties: `transition: transform 200ms ease-out` |
|
||||
| `scale(0)` entry animation | Start from `scale(0.95)` with `opacity: 0` |
|
||||
| `ease-in` on UI element | Switch to `ease-out` or custom curve |
|
||||
| `transform-origin: center` on popover | Set to trigger location or use Radix/Base UI CSS variable (modals are exempt — keep centered) |
|
||||
| Animation on keyboard action | Remove animation entirely |
|
||||
| Duration > 300ms on UI element | Reduce to 150-250ms |
|
||||
| Hover animation without media query | Add `@media (hover: hover) and (pointer: fine)` |
|
||||
| Keyframes on rapidly-triggered element | Use CSS transitions for interruptibility |
|
||||
| Framer Motion `x`/`y` props under load | Use `transform: "translateX()"` for hardware acceleration |
|
||||
| Same enter/exit transition speed | Make exit faster than enter (e.g., enter 2s, exit 200ms) |
|
||||
| Elements all appear at once | Add stagger delay (30-80ms between items) |
|
||||
177
.agents/skills/frontend-design/LICENSE.txt
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
45
.agents/skills/frontend-design/SKILL.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
---
|
||||
name: frontend-design
|
||||
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
|
||||
|
||||
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
|
||||
|
||||
## Design Thinking
|
||||
|
||||
Before coding, understand the context and commit to a BOLD aesthetic direction:
|
||||
|
||||
- **Purpose**: What problem does this interface solve? Who uses it?
|
||||
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
|
||||
- **Constraints**: Technical requirements (framework, performance, accessibility).
|
||||
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
|
||||
|
||||
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
|
||||
|
||||
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
|
||||
|
||||
- Production-grade and functional
|
||||
- Visually striking and memorable
|
||||
- Cohesive with a clear aesthetic point-of-view
|
||||
- Meticulously refined in every detail
|
||||
|
||||
## Frontend Aesthetics Guidelines
|
||||
|
||||
Focus on:
|
||||
|
||||
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
|
||||
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
|
||||
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
|
||||
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
|
||||
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
|
||||
|
||||
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
|
||||
|
||||
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
|
||||
|
||||
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
|
||||
|
||||
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
|
||||
122
.agents/skills/make-interfaces-feel-better/SKILL.md
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
---
|
||||
name: make-interfaces-feel-better
|
||||
description: Design engineering principles for making interfaces feel polished. Use when building UI components, reviewing frontend code, implementing animations, hover states, shadows, borders, typography, micro-interactions, enter/exit animations, or any visual detail work. Triggers on UI polish, design details, "make it feel better", "feels off", stagger animations, border radius, optical alignment, font smoothing, tabular numbers, image outlines, box shadows.
|
||||
---
|
||||
|
||||
# Details that make interfaces feel better
|
||||
|
||||
Great interfaces rarely come from a single thing. It's usually a collection of small details that compound into a great experience. Apply these principles when building or reviewing UI code.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Category | When to Use |
|
||||
| ----------------------------- | --------------------------------------------------------------------------------- |
|
||||
| [Typography](typography.md) | Text wrapping, font smoothing, tabular numbers |
|
||||
| [Surfaces](surfaces.md) | Border radius, optical alignment, shadows, image outlines, hit areas |
|
||||
| [Animations](animations.md) | Interruptible animations, enter/exit transitions, icon animations, scale on press |
|
||||
| [Performance](performance.md) | Transition specificity, `will-change` usage |
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 1. Concentric Border Radius
|
||||
|
||||
Outer radius = inner radius + padding. Mismatched radii on nested elements is the most common thing that makes interfaces feel off.
|
||||
|
||||
### 2. Optical Over Geometric Alignment
|
||||
|
||||
When geometric centering looks off, align optically. Buttons with icons, play triangles, and asymmetric icons all need manual adjustment.
|
||||
|
||||
### 3. Shadows Over Borders
|
||||
|
||||
Layer multiple transparent `box-shadow` values for natural depth. Shadows adapt to any background; solid borders don't.
|
||||
|
||||
### 4. Interruptible Animations
|
||||
|
||||
Use CSS transitions for interactive state changes — they can be interrupted mid-animation. Reserve keyframes for staged sequences that run once.
|
||||
|
||||
### 5. Split and Stagger Enter Animations
|
||||
|
||||
Don't animate a single container. Break content into semantic chunks and stagger each with ~100ms delay.
|
||||
|
||||
### 6. Subtle Exit Animations
|
||||
|
||||
Use a small fixed `translateY` instead of full height. Exits should be softer than enters.
|
||||
|
||||
### 7. Contextual Icon Animations
|
||||
|
||||
Animate icons with `opacity`, `scale`, and `blur` instead of toggling visibility. Use exactly these values: scale from `0.25` to `1`, opacity from `0` to `1`, blur from `4px` to `0px`. If the project has `motion` or `framer-motion` in `package.json`, use `transition: { type: "spring", duration: 0.3, bounce: 0 }` — bounce must always be `0`. If no motion library is installed, keep both icons in the DOM (one absolute-positioned) and cross-fade with CSS transitions using `cubic-bezier(0.2, 0, 0, 1)` — this gives both enter and exit animations without any dependency.
|
||||
|
||||
### 8. Font Smoothing
|
||||
|
||||
Apply `-webkit-font-smoothing: antialiased` to the root layout on macOS for crisper text.
|
||||
|
||||
### 9. Tabular Numbers
|
||||
|
||||
Use `font-variant-numeric: tabular-nums` for any dynamically updating numbers to prevent layout shift.
|
||||
|
||||
### 10. Text Wrapping
|
||||
|
||||
Use `text-wrap: balance` on headings. Use `text-wrap: pretty` for body text to avoid orphans.
|
||||
|
||||
### 11. Image Outlines
|
||||
|
||||
Add a subtle `1px` outline with low opacity to images for consistent depth.
|
||||
|
||||
### 12. Scale on Press
|
||||
|
||||
A subtle `scale(0.96)` on click gives buttons tactile feedback. Always use `0.96`. Never use a value smaller than `0.95` — anything below feels exaggerated. Add a `static` prop to disable it when motion would be distracting.
|
||||
|
||||
### 13. Skip Animation on Page Load
|
||||
|
||||
Use `initial={false}` on `AnimatePresence` to prevent enter animations on first render. Verify it doesn't break intentional entrance animations.
|
||||
|
||||
### 14. Never Use `transition: all`
|
||||
|
||||
Always specify exact properties: `transition-property: scale, opacity`. Tailwind's `transition-transform` covers `transform, translate, scale, rotate`.
|
||||
|
||||
### 15. Use `will-change` Sparingly
|
||||
|
||||
Only for `transform`, `opacity`, `filter` — properties the GPU can composite. Never use `will-change: all`. Only add when you notice first-frame stutter.
|
||||
|
||||
### 16. Minimum Hit Area
|
||||
|
||||
Interactive elements need at least 40×40px hit area. Extend with a pseudo-element if the visible element is smaller. Never let hit areas of two elements overlap.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
| Mistake | Fix |
|
||||
| -------------------------------------- | ------------------------------------------------- |
|
||||
| Same border radius on parent and child | Calculate `outerRadius = innerRadius + padding` |
|
||||
| Icons look off-center | Adjust optically with padding or fix SVG directly |
|
||||
| Hard borders between sections | Use layered `box-shadow` with transparency |
|
||||
| Jarring enter/exit animations | Split, stagger, and keep exits subtle |
|
||||
| Numbers cause layout shift | Apply `tabular-nums` |
|
||||
| Heavy text on macOS | Apply `antialiased` to root |
|
||||
| Animation plays on page load | Add `initial={false}` to `AnimatePresence` |
|
||||
| `transition: all` on elements | Specify exact properties |
|
||||
| First-frame animation stutter | Add `will-change: transform` (sparingly) |
|
||||
| Tiny hit areas on small controls | Extend with pseudo-element to 40×40px |
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- [ ] Nested rounded elements use concentric border radius
|
||||
- [ ] Icons are optically centered, not just geometrically
|
||||
- [ ] Shadows used instead of borders where appropriate
|
||||
- [ ] Enter animations are split and staggered
|
||||
- [ ] Exit animations are subtle
|
||||
- [ ] Dynamic numbers use tabular-nums
|
||||
- [ ] Font smoothing is applied
|
||||
- [ ] Headings use text-wrap: balance
|
||||
- [ ] Images have subtle outlines
|
||||
- [ ] Buttons use scale on press where appropriate
|
||||
- [ ] AnimatePresence uses `initial={false}` for default-state elements
|
||||
- [ ] No `transition: all` — only specific properties
|
||||
- [ ] `will-change` only on transform/opacity/filter, never `all`
|
||||
- [ ] Interactive elements have at least 40×40px hit area
|
||||
|
||||
## Reference Files
|
||||
|
||||
- [typography.md](typography.md) — Text wrapping, font smoothing, tabular numbers
|
||||
- [surfaces.md](surfaces.md) — Border radius, optical alignment, shadows, image outlines
|
||||
- [animations.md](animations.md) — Interruptible animations, enter/exit transitions, icon animations, scale on press
|
||||
- [performance.md](performance.md) — Transition specificity, `will-change` usage
|
||||
387
.agents/skills/make-interfaces-feel-better/animations.md
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
# Animations
|
||||
|
||||
Interruptible animations, enter/exit transitions, and contextual icon animations.
|
||||
|
||||
## Interruptible Animations
|
||||
|
||||
Users change intent mid-interaction. If animations aren't interruptible, the interface feels broken.
|
||||
|
||||
### CSS Transitions vs. Keyframes
|
||||
|
||||
| | CSS Transitions | CSS Keyframe Animations |
|
||||
| ----------------- | ----------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| **Behavior** | Interpolate toward latest state | Run on a fixed timeline |
|
||||
| **Interruptible** | Yes — retargets mid-animation | No — restarts from beginning |
|
||||
| **Use for** | Interactive state changes (hover, toggle, open/close) | Staged sequences that run once (enter animations, loading) |
|
||||
| **Duration** | Adapts to remaining distance | Fixed regardless of state |
|
||||
|
||||
```css
|
||||
/* Good — interruptible transition for a toggle */
|
||||
.drawer {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 200ms ease-out;
|
||||
}
|
||||
.drawer.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Clicking again mid-animation smoothly reverses — no jank */
|
||||
```
|
||||
|
||||
```css
|
||||
/* Bad — keyframe animation for interactive element */
|
||||
.drawer.open {
|
||||
animation: slideIn 200ms ease-out forwards;
|
||||
}
|
||||
|
||||
/* Closing mid-animation snaps or restarts — feels broken */
|
||||
```
|
||||
|
||||
**Rule:** Always prefer CSS transitions for interactive elements. Reserve keyframes for one-shot sequences.
|
||||
|
||||
## Enter Animations: Split and Stagger
|
||||
|
||||
Don't animate a single large container. Break content into semantic chunks and animate each individually.
|
||||
|
||||
### Step by Step
|
||||
|
||||
1. **Split** into logical groups (title, description, buttons)
|
||||
2. **Stagger** with ~100ms delay between groups
|
||||
3. **For titles**, consider splitting into individual words with ~80ms stagger
|
||||
4. **Combine** `opacity`, `blur`, and `translateY` for the enter effect
|
||||
|
||||
### Code Example
|
||||
|
||||
```tsx
|
||||
// Motion (Framer Motion) — staggered enter
|
||||
function PageHeader() {
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{
|
||||
visible: { transition: { staggerChildren: 0.1 } },
|
||||
}}
|
||||
>
|
||||
<motion.h1
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: 12, filter: "blur(4px)" },
|
||||
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
|
||||
}}
|
||||
>
|
||||
Welcome
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: 12, filter: "blur(4px)" },
|
||||
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
|
||||
}}
|
||||
>
|
||||
A description of the page.
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: 12, filter: "blur(4px)" },
|
||||
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
|
||||
}}
|
||||
>
|
||||
<Button>Get started</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### CSS-Only Stagger
|
||||
|
||||
```css
|
||||
.stagger-item {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
filter: blur(4px);
|
||||
animation: fadeInUp 400ms ease-out forwards;
|
||||
}
|
||||
|
||||
.stagger-item:nth-child(1) {
|
||||
animation-delay: 0ms;
|
||||
}
|
||||
.stagger-item:nth-child(2) {
|
||||
animation-delay: 100ms;
|
||||
}
|
||||
.stagger-item:nth-child(3) {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Exit Animations
|
||||
|
||||
Exit animations should be softer and less attention-grabbing than enter animations. The user's focus is moving to the next thing — don't fight for attention.
|
||||
|
||||
### Subtle Exit (Recommended)
|
||||
|
||||
```tsx
|
||||
// Small fixed translateY — indicates direction without drama
|
||||
<motion.div
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: -12,
|
||||
filter: "blur(4px)",
|
||||
transition: { duration: 0.15, ease: "easeIn" },
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</motion.div>
|
||||
```
|
||||
|
||||
### Full Exit (When Context Matters)
|
||||
|
||||
```tsx
|
||||
// Slide fully out — use when spatial context is important
|
||||
// (e.g., a card returning to a list, a drawer closing)
|
||||
<motion.div
|
||||
exit={{
|
||||
opacity: 0,
|
||||
x: "-100%",
|
||||
transition: { duration: 0.2, ease: "easeIn" },
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</motion.div>
|
||||
```
|
||||
|
||||
### Good vs. Bad
|
||||
|
||||
```css
|
||||
/* Good — subtle exit */
|
||||
.item-exit {
|
||||
opacity: 0;
|
||||
transform: translateY(-12px);
|
||||
transition:
|
||||
opacity 150ms ease-in,
|
||||
transform 150ms ease-in;
|
||||
}
|
||||
|
||||
/* Bad — dramatic exit that steals focus */
|
||||
.item-exit {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%) scale(0.5);
|
||||
transition: all 400ms ease-in;
|
||||
}
|
||||
|
||||
/* Bad — no exit animation at all (element just vanishes) */
|
||||
.item-exit {
|
||||
display: none;
|
||||
}
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- Use a small fixed `translateY` (e.g., `-12px`) instead of the full container height
|
||||
- Keep some directional movement to indicate where the element went
|
||||
- Exit duration should be shorter than enter duration (150ms vs 300ms)
|
||||
- Don't remove exit animations entirely — subtle motion preserves context
|
||||
|
||||
## Contextual Icon Animations
|
||||
|
||||
When icons appear or disappear contextually (on hover, on state change), animate them with `opacity`, `scale`, and `blur` rather than just toggling visibility.
|
||||
|
||||
### Motion Example
|
||||
|
||||
```tsx
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
function IconButton({ isActive, icon: Icon }) {
|
||||
return (
|
||||
<button>
|
||||
<AnimatePresence mode="popLayout">
|
||||
<motion.span
|
||||
key={isActive ? "active" : "inactive"}
|
||||
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
|
||||
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
|
||||
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
|
||||
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
|
||||
>
|
||||
<Icon />
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### CSS Transition Approach (No Motion)
|
||||
|
||||
If the project doesn't use Motion (Framer Motion), keep both icons in the DOM and cross-fade them with CSS transitions. Because neither icon unmounts, both enter and exit animate smoothly.
|
||||
|
||||
The trick: one icon is absolutely positioned on top of the other. Toggling state cross-fades them — the entering icon scales up from `0.25` while the exiting icon scales down to `0.25`, both with opacity and blur.
|
||||
|
||||
```tsx
|
||||
function IconButton({ isActive, ActiveIcon, InactiveIcon }) {
|
||||
return (
|
||||
<button>
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex items-center justify-center",
|
||||
"transition-[opacity,filter,scale] duration-300",
|
||||
"cubic-bezier(0.2, 0, 0, 1)",
|
||||
isActive
|
||||
? "scale-100 opacity-100 blur-0"
|
||||
: "scale-[0.25] opacity-0 blur-[4px]",
|
||||
)}
|
||||
>
|
||||
<ActiveIcon />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"transition-[opacity,filter,scale] duration-300",
|
||||
"cubic-bezier(0.2, 0, 0, 1)",
|
||||
isActive
|
||||
? "scale-[0.25] opacity-0 blur-[4px]"
|
||||
: "scale-100 opacity-100 blur-0",
|
||||
)}
|
||||
>
|
||||
<InactiveIcon />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The non-absolute icon (InactiveIcon) defines the layout size. The absolute icon (ActiveIcon) overlays it without affecting flow.
|
||||
|
||||
### Choosing Between Motion and CSS
|
||||
|
||||
| | Motion (Framer Motion) | CSS transitions (both icons in DOM) |
|
||||
| ------------------- | ----------------------------------- | ------------------------------------------------------ |
|
||||
| **Enter animation** | Yes | Yes |
|
||||
| **Exit animation** | Yes (via `AnimatePresence`) | Yes (cross-fade — icon never unmounts) |
|
||||
| **Spring physics** | Yes | No — use `cubic-bezier(0.2, 0, 0, 1)` as approximation |
|
||||
| **When to use** | Project already uses `motion/react` | No motion dependency, or keeping bundle small |
|
||||
|
||||
**Rule:** Check the project's `package.json` for `motion` or `framer-motion`. If present, use the Motion approach. If not, use the CSS cross-fade pattern — don't add a dependency just for icon transitions.
|
||||
|
||||
### When to Animate Icons
|
||||
|
||||
| Animate | Don't animate |
|
||||
| ----------------------------------------------- | ------------------------------- |
|
||||
| Icons that appear on hover (action buttons) | Static navigation icons |
|
||||
| State change icons (play → pause, like → liked) | Decorative icons |
|
||||
| Icons in contextual toolbars | Icons that are always visible |
|
||||
| Loading/success state indicators | Icon labels (text next to icon) |
|
||||
|
||||
**Important:** Always use exactly these values for contextual icon animations — do not deviate:
|
||||
|
||||
- `scale`: `0.25` → `1` (never use `0.5` or `0.6`)
|
||||
- `opacity`: `0` → `1`
|
||||
- `filter`: `"blur(4px)"` → `"blur(0px)"`
|
||||
- `transition`: `{ type: "spring", duration: 0.3, bounce: 0 }` — **bounce must always be `0`**, never `0.1` or any other value
|
||||
|
||||
## Scale on Press
|
||||
|
||||
A subtle scale-down on click gives buttons tactile feedback. Always use `scale(0.96)`. Never use a value smaller than `0.95` — anything below feels exaggerated. Use CSS transitions for interruptibility — if the user releases mid-press, it should smoothly return.
|
||||
|
||||
Not every button needs this. Add a `static` prop to your button component that disables the scale effect when the motion would be distracting.
|
||||
|
||||
### CSS Example
|
||||
|
||||
```css
|
||||
.button {
|
||||
transition-property: scale;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
.button:active {
|
||||
scale: 0.96;
|
||||
}
|
||||
```
|
||||
|
||||
### Tailwind Example
|
||||
|
||||
```tsx
|
||||
<button className="transition-transform duration-150 ease-out active:scale-[0.96]">
|
||||
Click me
|
||||
</button>
|
||||
```
|
||||
|
||||
### Motion Example
|
||||
|
||||
```tsx
|
||||
<motion.button whileTap={{ scale: 0.96 }}>Click me</motion.button>
|
||||
```
|
||||
|
||||
### Static Prop Pattern
|
||||
|
||||
Extract the scale class into a variable and conditionally apply it based on a `static` prop:
|
||||
|
||||
```tsx
|
||||
const tapScale = "active:not-disabled:scale-[0.96]";
|
||||
|
||||
function Button({ static: isStatic, className, children, ...props }) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"transition-transform duration-150 ease-out",
|
||||
!isStatic && tapScale,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
<Button>Click me</Button> {/* scales on press */}
|
||||
<Button static>Submit</Button> {/* no scale */}
|
||||
```
|
||||
|
||||
## Skip Animation on Page Load
|
||||
|
||||
Use `initial={false}` on `AnimatePresence` to prevent enter animations from firing on first render. Elements that are already in their default state shouldn't animate in on page load — only on subsequent state changes.
|
||||
|
||||
### When It Works
|
||||
|
||||
```tsx
|
||||
// Good — icon doesn't animate in on mount, only on state change
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
<motion.span
|
||||
key={isActive ? "active" : "inactive"}
|
||||
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
|
||||
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
|
||||
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
|
||||
>
|
||||
<Icon />
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
```
|
||||
|
||||
Works well for: icon swaps, toggles, tabs, segmented controls — anything that has a default state on page load.
|
||||
|
||||
### When It Breaks
|
||||
|
||||
Don't use `initial={false}` when the component relies on its `initial` prop to set up a first-time enter animation, like a staggered page hero or a loading state. In those cases, removing the initial animation skips the entire entrance.
|
||||
|
||||
```tsx
|
||||
// Bad — initial={false} would skip the staggered page enter entirely
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.div initial="hidden" animate="visible" variants={...}>
|
||||
...
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
```
|
||||
|
||||
Verify the component still looks right on a full page refresh before applying this.
|
||||
88
.agents/skills/make-interfaces-feel-better/performance.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Performance
|
||||
|
||||
Transition specificity and GPU compositing hints.
|
||||
|
||||
## Transition Only What Changes
|
||||
|
||||
Never use `transition: all` or Tailwind's `transition` shorthand (which maps to `transition-property: all`). Always specify the exact properties that change.
|
||||
|
||||
### Why
|
||||
|
||||
- `transition: all` forces the browser to watch every property for changes
|
||||
- Causes unexpected transitions on properties you didn't intend to animate (colors, padding, shadows)
|
||||
- Prevents browser optimizations
|
||||
|
||||
### CSS Example
|
||||
|
||||
```css
|
||||
/* Good — only transition what changes */
|
||||
.button {
|
||||
transition-property: scale, background-color;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
/* Bad — transition everything */
|
||||
.button {
|
||||
transition: all 150ms ease-out;
|
||||
}
|
||||
```
|
||||
|
||||
### Tailwind
|
||||
|
||||
```tsx
|
||||
// Good — explicit properties
|
||||
<button className="transition-[scale,background-color] duration-150 ease-out">
|
||||
|
||||
// Bad — transition all
|
||||
<button className="transition duration-150 ease-out">
|
||||
```
|
||||
|
||||
### Tailwind `transition-transform` Note
|
||||
|
||||
`transition-transform` in Tailwind maps to `transition-property: transform, translate, scale, rotate` — it covers all transform-related properties, not just `transform`. Use this when you're only animating transforms. For multiple non-transform properties, use the bracket syntax: `transition-[scale,opacity,filter]`.
|
||||
|
||||
## Use `will-change` Sparingly
|
||||
|
||||
`will-change` hints the browser to pre-promote an element to its own GPU compositing layer. Without it, the browser promotes the element only when the animation starts — that one-time layer promotion can cause a micro-stutter on the first frame.
|
||||
|
||||
This particularly helps when an element is changing `scale`, `rotation`, or moving around with `transform`. For other properties, it doesn't help much — the browser can't composite them on the GPU anyway.
|
||||
|
||||
### Rules
|
||||
|
||||
```css
|
||||
/* Good — specific property that benefits from GPU compositing */
|
||||
.animated-card {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Good — multiple compositor-friendly properties */
|
||||
.animated-card {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Bad — never use will-change: all */
|
||||
.animated-card {
|
||||
will-change: all;
|
||||
}
|
||||
|
||||
/* Bad — properties that can't be GPU-composited anyway */
|
||||
.animated-card {
|
||||
will-change: background-color, padding;
|
||||
}
|
||||
```
|
||||
|
||||
### Useful Properties
|
||||
|
||||
| Property | GPU-compositable | Worth using `will-change` |
|
||||
| -------------------------------- | ---------------- | ------------------------- |
|
||||
| `transform` | Yes | Yes |
|
||||
| `opacity` | Yes | Yes |
|
||||
| `filter` (blur, brightness) | Yes | Yes |
|
||||
| `clip-path` | Yes | Yes |
|
||||
| `top`, `left`, `width`, `height` | No | No |
|
||||
| `background`, `border`, `color` | No | No |
|
||||
|
||||
### When to Skip
|
||||
|
||||
Modern browsers are already good at optimizing on their own. Only add `will-change` when you notice first-frame stutter — Safari in particular benefits from it. Don't add it preemptively to every animated element; each extra compositing layer costs memory.
|
||||
245
.agents/skills/make-interfaces-feel-better/surfaces.md
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
# Surfaces
|
||||
|
||||
Border radius, optical alignment, shadows, and image outlines.
|
||||
|
||||
## Concentric Border Radius
|
||||
|
||||
When nesting rounded elements, the outer radius must equal the inner radius plus the padding between them:
|
||||
|
||||
```
|
||||
outerRadius = innerRadius + padding
|
||||
```
|
||||
|
||||
This rule is most useful when nested surfaces are close together. If padding is larger than `24px`, treat the layers as separate surfaces and choose each radius independently instead of forcing strict concentric math.
|
||||
|
||||
### Example
|
||||
|
||||
```css
|
||||
/* Good — concentric radii */
|
||||
.card {
|
||||
border-radius: 20px; /* 12 + 8 */
|
||||
padding: 8px;
|
||||
}
|
||||
.card-inner {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Bad — same radius on both */
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
.card-inner {
|
||||
border-radius: 12px;
|
||||
}
|
||||
```
|
||||
|
||||
### Tailwind Example
|
||||
|
||||
```tsx
|
||||
// Good — outer radius accounts for padding
|
||||
<div className="rounded-2xl p-2"> {/* 16px radius, 8px padding */}
|
||||
<div className="rounded-lg"> {/* 8px radius = 16 - 8 ✓ */}
|
||||
...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Bad — same radius on both
|
||||
<div className="rounded-xl p-2">
|
||||
<div className="rounded-xl"> {/* same radius, looks off */}
|
||||
...
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Mismatched border radii on nested elements is one of the most common things that makes interfaces feel off. Always calculate concentrically.
|
||||
|
||||
## Optical Alignment
|
||||
|
||||
When geometric centering looks off, align optically instead.
|
||||
|
||||
### Buttons with Text + Icon
|
||||
|
||||
Use slightly less padding on the icon side to make the button feel balanced. A reliable rule of thumb is:
|
||||
`icon-side padding = text-side padding - 2px`.
|
||||
|
||||
```css
|
||||
/* Good — less padding on icon side */
|
||||
.button-with-icon {
|
||||
padding-left: 16px;
|
||||
padding-right: 14px; /* icon side = text side - 2px */
|
||||
}
|
||||
|
||||
/* Bad — equal padding looks like icon is pushed too far right */
|
||||
.button-with-icon {
|
||||
padding: 0 16px;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Tailwind
|
||||
<button className="flex items-center gap-2 pl-4 pr-3.5">
|
||||
<span>Continue</span>
|
||||
<ArrowRightIcon />
|
||||
</button>
|
||||
```
|
||||
|
||||
### Play Button Triangles
|
||||
|
||||
Play icons are triangular and their geometric center is not their visual center. Shift slightly right:
|
||||
|
||||
```css
|
||||
/* Good — optically centered */
|
||||
.play-button svg {
|
||||
margin-left: 2px; /* shift right to account for triangle shape */
|
||||
}
|
||||
|
||||
/* Bad — geometrically centered but looks off */
|
||||
.play-button svg {
|
||||
/* no adjustment */
|
||||
}
|
||||
```
|
||||
|
||||
### Asymmetric Icons (Stars, Arrows, Carets)
|
||||
|
||||
Some icons have uneven visual weight. The best fix is adjusting the SVG directly so no extra margin/padding is needed in the component code.
|
||||
|
||||
```tsx
|
||||
// Best — fix in the SVG itself
|
||||
// Adjust the viewBox or path to visually center the icon
|
||||
|
||||
// Fallback — adjust with margin
|
||||
<span className="ml-px">
|
||||
<StarIcon />
|
||||
</span>
|
||||
```
|
||||
|
||||
## Shadows Instead of Borders
|
||||
|
||||
For **buttons, cards, and containers** that use a border for depth or elevation, prefer replacing it with a subtle `box-shadow`. Shadows adapt to any background since they use transparency; solid borders don't. This also helps when using images or multiple colors as backgrounds — solid border colors don't work well on backgrounds other than the ones they were designed for.
|
||||
|
||||
**Do not apply this to dividers** (`border-b`, `border-t`, side borders) or any border whose purpose is layout separation rather than element depth. Those should stay as borders.
|
||||
|
||||
### Shadow as Border (Light Mode)
|
||||
|
||||
The shadow is comprised of three layers. The first acts as a 1px border ring, the second adds subtle lift, and the third provides ambient depth:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--shadow-border:
|
||||
0px 0px 0px 1px rgba(0, 0, 0, 0.06), 0px 1px 2px -1px rgba(0, 0, 0, 0.06),
|
||||
0px 2px 4px 0px rgba(0, 0, 0, 0.04);
|
||||
--shadow-border-hover:
|
||||
0px 0px 0px 1px rgba(0, 0, 0, 0.08), 0px 1px 2px -1px rgba(0, 0, 0, 0.08),
|
||||
0px 2px 4px 0px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
```
|
||||
|
||||
### Shadow as Border (Dark Mode)
|
||||
|
||||
In dark mode, simplify to a single white ring — layered depth shadows aren't visible on dark backgrounds:
|
||||
|
||||
```css
|
||||
/* Dark mode — adapt to whatever setup the project uses
|
||||
(prefers-color-scheme, class, data attribute, etc.) */
|
||||
--shadow-border: 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
--shadow-border-hover: 0 0 0 1px rgba(255, 255, 255, 0.13);
|
||||
```
|
||||
|
||||
### Usage with Hover Transition
|
||||
|
||||
Apply the variable and add `transition-[box-shadow]` for a smooth hover:
|
||||
|
||||
```css
|
||||
.card {
|
||||
box-shadow: var(--shadow-border);
|
||||
transition-property: box-shadow;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-border-hover);
|
||||
}
|
||||
```
|
||||
|
||||
### When to Use Shadows vs. Borders
|
||||
|
||||
| Use shadows | Use borders |
|
||||
| ------------------------------------- | --------------------------------------- |
|
||||
| Cards, containers with depth | Dividers between list items |
|
||||
| Buttons with bordered styles | Table cell boundaries |
|
||||
| Elevated elements (dropdowns, modals) | Form input outlines (for accessibility) |
|
||||
| Elements on varied backgrounds | Hairline separators in dense UI |
|
||||
| Hover/focus states for lift effect | |
|
||||
|
||||
## Image Outlines
|
||||
|
||||
Add a subtle `1px` outline with low opacity to images. This creates consistent depth, especially in design systems where other elements use borders or shadows.
|
||||
|
||||
### Light Mode
|
||||
|
||||
```css
|
||||
img {
|
||||
outline: 1px solid rgba(0, 0, 0, 0.1);
|
||||
outline-offset: -1px; /* inset so it doesn't add to layout */
|
||||
}
|
||||
```
|
||||
|
||||
### Dark Mode
|
||||
|
||||
```css
|
||||
img {
|
||||
outline: 1px solid rgba(255, 255, 255, 0.1);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
```
|
||||
|
||||
### Tailwind with Dark Mode
|
||||
|
||||
```tsx
|
||||
<img
|
||||
className="outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10"
|
||||
src={src}
|
||||
alt={alt}
|
||||
/>
|
||||
```
|
||||
|
||||
**Why outline instead of border?** `outline` doesn't affect layout (no added width/height), and `outline-offset: -1px` keeps it inset so images stay their intended size.
|
||||
|
||||
## Minimum Hit Area
|
||||
|
||||
Interactive elements should have a minimum hit area of 44×44px (WCAG) or at least 40×40px. If the visible element is smaller (e.g., a 20×20 checkbox), extend the hit area with a pseudo-element.
|
||||
|
||||
### CSS Example
|
||||
|
||||
```css
|
||||
/* Small checkbox with expanded hit area */
|
||||
.checkbox {
|
||||
position: relative;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.checkbox::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
```
|
||||
|
||||
### Tailwind Example
|
||||
|
||||
```tsx
|
||||
<button className="after:-translate-1/2 relative size-5 after:absolute after:left-1/2 after:top-1/2 after:size-10">
|
||||
<CheckIcon />
|
||||
</button>
|
||||
```
|
||||
|
||||
### Collision Rule
|
||||
|
||||
If the extended hit area overlaps another interactive element, shrink the pseudo-element — but make it as large as possible without colliding. Two interactive elements should never have overlapping hit areas.
|
||||
125
.agents/skills/make-interfaces-feel-better/typography.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# Typography
|
||||
|
||||
Typography rendering details that make interfaces feel better.
|
||||
|
||||
## Text Wrapping
|
||||
|
||||
### text-wrap: balance
|
||||
|
||||
Distributes text evenly across lines, preventing orphaned words on headings and short text blocks. **Only works on blocks of 6 lines or fewer** (Chromium) or 10 lines or fewer (Firefox) — the balancing algorithm is computationally expensive, so browsers limit it to short text.
|
||||
|
||||
```css
|
||||
/* Good — even line lengths on short text */
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
```
|
||||
|
||||
```css
|
||||
/* Bad — default wrapping leaves orphans */
|
||||
h1 {
|
||||
/* no text-wrap rule → "Read our
|
||||
blog" instead of balanced lines */
|
||||
}
|
||||
```
|
||||
|
||||
```css
|
||||
/* Bad — balance on long paragraphs (silently ignored, wastes intent) */
|
||||
.article-body p {
|
||||
text-wrap: balance;
|
||||
}
|
||||
```
|
||||
|
||||
**Tailwind:** `text-balance`
|
||||
|
||||
### text-wrap: pretty
|
||||
|
||||
Optimizes the last line to avoid orphans using a slower algorithm that favors better typography over performance. Unlike `balance`, it works on longer text — use this for body copy where you want to minimize orphans without the 6-line limit.
|
||||
|
||||
```css
|
||||
p {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
```
|
||||
|
||||
### When to Use Which
|
||||
|
||||
| Scenario | Use |
|
||||
| --------------------------------------- | ----------------------- |
|
||||
| Headings, titles, short text (≤6 lines) | `text-wrap: balance` |
|
||||
| Body paragraphs, descriptions | `text-wrap: pretty` |
|
||||
| Code blocks, pre-formatted text | Neither — leave default |
|
||||
|
||||
## Font Smoothing (macOS)
|
||||
|
||||
On macOS, text renders heavier than intended by default. Apply antialiased smoothing to the root layout so all text renders crisper and thinner.
|
||||
|
||||
```css
|
||||
/* CSS */
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Tailwind — apply to root layout
|
||||
<html className="antialiased">
|
||||
```
|
||||
|
||||
### Good vs. Bad
|
||||
|
||||
```css
|
||||
/* Good — applied once at the root */
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Bad — applied per-element, inconsistent */
|
||||
.heading {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.body {
|
||||
/* no smoothing → heavier than heading */
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** This only affects macOS rendering. Other platforms ignore these properties, so it's safe to apply universally.
|
||||
|
||||
## Tabular Numbers
|
||||
|
||||
When numbers update dynamically (counters, prices, timers, table columns), use tabular-nums to make all digits equal width. This prevents layout shift as values change.
|
||||
|
||||
```css
|
||||
/* CSS */
|
||||
.counter {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Tailwind
|
||||
<span className="tabular-nums">{count}</span>
|
||||
```
|
||||
|
||||
### When to Use
|
||||
|
||||
| Use tabular-nums | Don't use tabular-nums |
|
||||
| --------------------------- | ------------------------ |
|
||||
| Counters and timers | Static display numbers |
|
||||
| Prices that update | Decorative large numbers |
|
||||
| Table columns with numbers | Phone numbers, zip codes |
|
||||
| Animated number transitions | Version numbers (v2.1.0) |
|
||||
| Scoreboards, dashboards | |
|
||||
|
||||
### Caveat
|
||||
|
||||
Some fonts (like Inter) change the visual appearance of numerals with this property — specifically, the digit `1` becomes wider and centered. This is expected behavior and usually desirable for alignment, but verify it looks right in your specific font.
|
||||
|
||||
```css
|
||||
/* With Inter font:
|
||||
Default: 1234 → proportional, "1" is narrow
|
||||
Tabular: 1234 → all digits equal width, "1" centered */
|
||||
```
|
||||
2947
.agents/skills/vercel-react-best-practices/AGENTS.md
Normal file
127
.agents/skills/vercel-react-best-practices/README.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# React Best Practices
|
||||
|
||||
A structured repository for creating and maintaining React Best Practices optimized for agents and LLMs.
|
||||
|
||||
## Structure
|
||||
|
||||
- `rules/` - Individual rule files (one per rule)
|
||||
- `_sections.md` - Section metadata (titles, impacts, descriptions)
|
||||
- `_template.md` - Template for creating new rules
|
||||
- `area-description.md` - Individual rule files
|
||||
- `src/` - Build scripts and utilities
|
||||
- `metadata.json` - Document metadata (version, organization, abstract)
|
||||
- **`AGENTS.md`** - Compiled output (generated)
|
||||
- **`test-cases.json`** - Test cases for LLM evaluation (generated)
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Install dependencies:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. Build AGENTS.md from rules:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
3. Validate rule files:
|
||||
|
||||
```bash
|
||||
pnpm validate
|
||||
```
|
||||
|
||||
4. Extract test cases:
|
||||
```bash
|
||||
pnpm extract-tests
|
||||
```
|
||||
|
||||
## Creating a New Rule
|
||||
|
||||
1. Copy `rules/_template.md` to `rules/area-description.md`
|
||||
2. Choose the appropriate area prefix:
|
||||
- `async-` for Eliminating Waterfalls (Section 1)
|
||||
- `bundle-` for Bundle Size Optimization (Section 2)
|
||||
- `server-` for Server-Side Performance (Section 3)
|
||||
- `client-` for Client-Side Data Fetching (Section 4)
|
||||
- `rerender-` for Re-render Optimization (Section 5)
|
||||
- `rendering-` for Rendering Performance (Section 6)
|
||||
- `js-` for JavaScript Performance (Section 7)
|
||||
- `advanced-` for Advanced Patterns (Section 8)
|
||||
3. Fill in the frontmatter and content
|
||||
4. Ensure you have clear examples with explanations
|
||||
5. Run `pnpm build` to regenerate AGENTS.md and test-cases.json
|
||||
|
||||
## Rule File Structure
|
||||
|
||||
Each rule file should follow this structure:
|
||||
|
||||
````markdown
|
||||
---
|
||||
title: Rule Title Here
|
||||
impact: MEDIUM
|
||||
impactDescription: Optional description
|
||||
tags: tag1, tag2, tag3
|
||||
---
|
||||
|
||||
## Rule Title Here
|
||||
|
||||
Brief explanation of the rule and why it matters.
|
||||
|
||||
**Incorrect (description of what's wrong):**
|
||||
|
||||
```typescript
|
||||
// Bad code example
|
||||
```
|
||||
````
|
||||
|
||||
**Correct (description of what's right):**
|
||||
|
||||
```typescript
|
||||
// Good code example
|
||||
```
|
||||
|
||||
Optional explanatory text after examples.
|
||||
|
||||
Reference: [Link](https://example.com)
|
||||
|
||||
## File Naming Convention
|
||||
|
||||
- Files starting with `_` are special (excluded from build)
|
||||
- Rule files: `area-description.md` (e.g., `async-parallel.md`)
|
||||
- Section is automatically inferred from filename prefix
|
||||
- Rules are sorted alphabetically by title within each section
|
||||
- IDs (e.g., 1.1, 1.2) are auto-generated during build
|
||||
|
||||
## Impact Levels
|
||||
|
||||
- `CRITICAL` - Highest priority, major performance gains
|
||||
- `HIGH` - Significant performance improvements
|
||||
- `MEDIUM-HIGH` - Moderate-high gains
|
||||
- `MEDIUM` - Moderate performance improvements
|
||||
- `LOW-MEDIUM` - Low-medium gains
|
||||
- `LOW` - Incremental improvements
|
||||
|
||||
## Scripts
|
||||
|
||||
- `pnpm build` - Compile rules into AGENTS.md
|
||||
- `pnpm validate` - Validate all rule files
|
||||
- `pnpm extract-tests` - Extract test cases for LLM evaluation
|
||||
- `pnpm dev` - Build and validate
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding or modifying rules:
|
||||
|
||||
1. Use the correct filename prefix for your section
|
||||
2. Follow the `_template.md` structure
|
||||
3. Include clear bad/good examples with explanations
|
||||
4. Add appropriate tags
|
||||
5. Run `pnpm build` to regenerate AGENTS.md and test-cases.json
|
||||
6. Rules are automatically sorted by title - no need to manage numbers!
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Originally created by [@shuding](https://x.com/shuding) at [Vercel](https://vercel.com).
|
||||
139
.agents/skills/vercel-react-best-practices/SKILL.md
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
---
|
||||
name: vercel-react-best-practices
|
||||
description: React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.
|
||||
license: MIT
|
||||
metadata:
|
||||
author: vercel
|
||||
version: "1.0.0"
|
||||
---
|
||||
|
||||
# Vercel React Best Practices
|
||||
|
||||
Comprehensive performance optimization guide for React and Next.js applications, maintained by Vercel. Contains 58 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
|
||||
|
||||
## When to Apply
|
||||
|
||||
Reference these guidelines when:
|
||||
|
||||
- Writing new React components or Next.js pages
|
||||
- Implementing data fetching (client or server-side)
|
||||
- Reviewing code for performance issues
|
||||
- Refactoring existing React/Next.js code
|
||||
- Optimizing bundle size or load times
|
||||
|
||||
## Rule Categories by Priority
|
||||
|
||||
| Priority | Category | Impact | Prefix |
|
||||
| -------- | ------------------------- | ----------- | ------------ |
|
||||
| 1 | Eliminating Waterfalls | CRITICAL | `async-` |
|
||||
| 2 | Bundle Size Optimization | CRITICAL | `bundle-` |
|
||||
| 3 | Server-Side Performance | HIGH | `server-` |
|
||||
| 4 | Client-Side Data Fetching | MEDIUM-HIGH | `client-` |
|
||||
| 5 | Re-render Optimization | MEDIUM | `rerender-` |
|
||||
| 6 | Rendering Performance | MEDIUM | `rendering-` |
|
||||
| 7 | JavaScript Performance | LOW-MEDIUM | `js-` |
|
||||
| 8 | Advanced Patterns | LOW | `advanced-` |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### 1. Eliminating Waterfalls (CRITICAL)
|
||||
|
||||
- `async-defer-await` - Move await into branches where actually used
|
||||
- `async-parallel` - Use Promise.all() for independent operations
|
||||
- `async-dependencies` - Use better-all for partial dependencies
|
||||
- `async-api-routes` - Start promises early, await late in API routes
|
||||
- `async-suspense-boundaries` - Use Suspense to stream content
|
||||
|
||||
### 2. Bundle Size Optimization (CRITICAL)
|
||||
|
||||
- `bundle-barrel-imports` - Import directly, avoid barrel files
|
||||
- `bundle-dynamic-imports` - Use next/dynamic for heavy components
|
||||
- `bundle-defer-third-party` - Load analytics/logging after hydration
|
||||
- `bundle-conditional` - Load modules only when feature is activated
|
||||
- `bundle-preload` - Preload on hover/focus for perceived speed
|
||||
|
||||
### 3. Server-Side Performance (HIGH)
|
||||
|
||||
- `server-auth-actions` - Authenticate server actions like API routes
|
||||
- `server-cache-react` - Use React.cache() for per-request deduplication
|
||||
- `server-cache-lru` - Use LRU cache for cross-request caching
|
||||
- `server-dedup-props` - Avoid duplicate serialization in RSC props
|
||||
- `server-hoist-static-io` - Hoist static I/O (fonts, logos) to module level
|
||||
- `server-serialization` - Minimize data passed to client components
|
||||
- `server-parallel-fetching` - Restructure components to parallelize fetches
|
||||
- `server-after-nonblocking` - Use after() for non-blocking operations
|
||||
|
||||
### 4. Client-Side Data Fetching (MEDIUM-HIGH)
|
||||
|
||||
- `client-swr-dedup` - Use SWR for automatic request deduplication
|
||||
- `client-event-listeners` - Deduplicate global event listeners
|
||||
- `client-passive-event-listeners` - Use passive listeners for scroll
|
||||
- `client-localstorage-schema` - Version and minimize localStorage data
|
||||
|
||||
### 5. Re-render Optimization (MEDIUM)
|
||||
|
||||
- `rerender-defer-reads` - Don't subscribe to state only used in callbacks
|
||||
- `rerender-memo` - Extract expensive work into memoized components
|
||||
- `rerender-memo-with-default-value` - Hoist default non-primitive props
|
||||
- `rerender-dependencies` - Use primitive dependencies in effects
|
||||
- `rerender-derived-state` - Subscribe to derived booleans, not raw values
|
||||
- `rerender-derived-state-no-effect` - Derive state during render, not effects
|
||||
- `rerender-functional-setstate` - Use functional setState for stable callbacks
|
||||
- `rerender-lazy-state-init` - Pass function to useState for expensive values
|
||||
- `rerender-simple-expression-in-memo` - Avoid memo for simple primitives
|
||||
- `rerender-move-effect-to-event` - Put interaction logic in event handlers
|
||||
- `rerender-transitions` - Use startTransition for non-urgent updates
|
||||
- `rerender-use-ref-transient-values` - Use refs for transient frequent values
|
||||
|
||||
### 6. Rendering Performance (MEDIUM)
|
||||
|
||||
- `rendering-animate-svg-wrapper` - Animate div wrapper, not SVG element
|
||||
- `rendering-content-visibility` - Use content-visibility for long lists
|
||||
- `rendering-hoist-jsx` - Extract static JSX outside components
|
||||
- `rendering-svg-precision` - Reduce SVG coordinate precision
|
||||
- `rendering-hydration-no-flicker` - Use inline script for client-only data
|
||||
- `rendering-hydration-suppress-warning` - Suppress expected mismatches
|
||||
- `rendering-activity` - Use Activity component for show/hide
|
||||
- `rendering-conditional-render` - Use ternary, not && for conditionals
|
||||
- `rendering-usetransition-loading` - Prefer useTransition for loading state
|
||||
|
||||
### 7. JavaScript Performance (LOW-MEDIUM)
|
||||
|
||||
- `js-batch-dom-css` - Group CSS changes via classes or cssText
|
||||
- `js-index-maps` - Build Map for repeated lookups
|
||||
- `js-cache-property-access` - Cache object properties in loops
|
||||
- `js-cache-function-results` - Cache function results in module-level Map
|
||||
- `js-cache-storage` - Cache localStorage/sessionStorage reads
|
||||
- `js-combine-iterations` - Combine multiple filter/map into one loop
|
||||
- `js-length-check-first` - Check array length before expensive comparison
|
||||
- `js-early-exit` - Return early from functions
|
||||
- `js-hoist-regexp` - Hoist RegExp creation outside loops
|
||||
- `js-min-max-loop` - Use loop for min/max instead of sort
|
||||
- `js-set-map-lookups` - Use Set/Map for O(1) lookups
|
||||
- `js-tosorted-immutable` - Use toSorted() for immutability
|
||||
|
||||
### 8. Advanced Patterns (LOW)
|
||||
|
||||
- `advanced-event-handler-refs` - Store event handlers in refs
|
||||
- `advanced-init-once` - Initialize app once per app load
|
||||
- `advanced-use-latest` - useLatest for stable callback refs
|
||||
|
||||
## How to Use
|
||||
|
||||
Read individual rule files for detailed explanations and code examples:
|
||||
|
||||
```
|
||||
rules/async-parallel.md
|
||||
rules/bundle-barrel-imports.md
|
||||
```
|
||||
|
||||
Each rule file contains:
|
||||
|
||||
- Brief explanation of why it matters
|
||||
- Incorrect code example with explanation
|
||||
- Correct code example with explanation
|
||||
- Additional context and references
|
||||
|
||||
## Full Compiled Document
|
||||
|
||||
For the complete guide with all rules expanded: `AGENTS.md`
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
---
|
||||
title: Store Event Handlers in Refs
|
||||
impact: LOW
|
||||
impactDescription: stable subscriptions
|
||||
tags: advanced, hooks, refs, event-handlers, optimization
|
||||
---
|
||||
|
||||
## Store Event Handlers in Refs
|
||||
|
||||
Store callbacks in refs when used in effects that shouldn't re-subscribe on callback changes.
|
||||
|
||||
**Incorrect (re-subscribes on every render):**
|
||||
|
||||
```tsx
|
||||
function useWindowEvent(event: string, handler: (e) => void) {
|
||||
useEffect(() => {
|
||||
window.addEventListener(event, handler);
|
||||
return () => window.removeEventListener(event, handler);
|
||||
}, [event, handler]);
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (stable subscription):**
|
||||
|
||||
```tsx
|
||||
function useWindowEvent(event: string, handler: (e) => void) {
|
||||
const handlerRef = useRef(handler);
|
||||
useEffect(() => {
|
||||
handlerRef.current = handler;
|
||||
}, [handler]);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (e) => handlerRef.current(e);
|
||||
window.addEventListener(event, listener);
|
||||
return () => window.removeEventListener(event, listener);
|
||||
}, [event]);
|
||||
}
|
||||
```
|
||||
|
||||
**Alternative: use `useEffectEvent` if you're on latest React:**
|
||||
|
||||
```tsx
|
||||
import { useEffectEvent } from "react";
|
||||
|
||||
function useWindowEvent(event: string, handler: (e) => void) {
|
||||
const onEvent = useEffectEvent(handler);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener(event, onEvent);
|
||||
return () => window.removeEventListener(event, onEvent);
|
||||
}, [event]);
|
||||
}
|
||||
```
|
||||
|
||||
`useEffectEvent` provides a cleaner API for the same pattern: it creates a stable function reference that always calls the latest version of the handler.
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
---
|
||||
title: Initialize App Once, Not Per Mount
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: avoids duplicate init in development
|
||||
tags: initialization, useEffect, app-startup, side-effects
|
||||
---
|
||||
|
||||
## Initialize App Once, Not Per Mount
|
||||
|
||||
Do not put app-wide initialization that must run once per app load inside `useEffect([])` of a component. Components can remount and effects will re-run. Use a module-level guard or top-level init in the entry module instead.
|
||||
|
||||
**Incorrect (runs twice in dev, re-runs on remount):**
|
||||
|
||||
```tsx
|
||||
function Comp() {
|
||||
useEffect(() => {
|
||||
loadFromStorage();
|
||||
checkAuthToken();
|
||||
}, []);
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (once per app load):**
|
||||
|
||||
```tsx
|
||||
let didInit = false;
|
||||
|
||||
function Comp() {
|
||||
useEffect(() => {
|
||||
if (didInit) return;
|
||||
didInit = true;
|
||||
loadFromStorage();
|
||||
checkAuthToken();
|
||||
}, []);
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [Initializing the application](https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application)
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
title: useEffectEvent for Stable Callback Refs
|
||||
impact: LOW
|
||||
impactDescription: prevents effect re-runs
|
||||
tags: advanced, hooks, useEffectEvent, refs, optimization
|
||||
---
|
||||
|
||||
## useEffectEvent for Stable Callback Refs
|
||||
|
||||
Access latest values in callbacks without adding them to dependency arrays. Prevents effect re-runs while avoiding stale closures.
|
||||
|
||||
**Incorrect (effect re-runs on every callback change):**
|
||||
|
||||
```tsx
|
||||
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => onSearch(query), 300);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [query, onSearch]);
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (using React's useEffectEvent):**
|
||||
|
||||
```tsx
|
||||
import { useEffectEvent } from "react";
|
||||
|
||||
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const onSearchEvent = useEffectEvent(onSearch);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => onSearchEvent(query), 300);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [query]);
|
||||
}
|
||||
```
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
title: Prevent Waterfall Chains in API Routes
|
||||
impact: CRITICAL
|
||||
impactDescription: 2-10× improvement
|
||||
tags: api-routes, server-actions, waterfalls, parallelization
|
||||
---
|
||||
|
||||
## Prevent Waterfall Chains in API Routes
|
||||
|
||||
In API routes and Server Actions, start independent operations immediately, even if you don't await them yet.
|
||||
|
||||
**Incorrect (config waits for auth, data waits for both):**
|
||||
|
||||
```typescript
|
||||
export async function GET(request: Request) {
|
||||
const session = await auth();
|
||||
const config = await fetchConfig();
|
||||
const data = await fetchData(session.user.id);
|
||||
return Response.json({ data, config });
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (auth and config start immediately):**
|
||||
|
||||
```typescript
|
||||
export async function GET(request: Request) {
|
||||
const sessionPromise = auth();
|
||||
const configPromise = fetchConfig();
|
||||
const session = await sessionPromise;
|
||||
const [config, data] = await Promise.all([
|
||||
configPromise,
|
||||
fetchData(session.user.id),
|
||||
]);
|
||||
return Response.json({ data, config });
|
||||
}
|
||||
```
|
||||
|
||||
For operations with more complex dependency chains, use `better-all` to automatically maximize parallelism (see Dependency-Based Parallelization).
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
---
|
||||
title: Defer Await Until Needed
|
||||
impact: HIGH
|
||||
impactDescription: avoids blocking unused code paths
|
||||
tags: async, await, conditional, optimization
|
||||
---
|
||||
|
||||
## Defer Await Until Needed
|
||||
|
||||
Move `await` operations into the branches where they're actually used to avoid blocking code paths that don't need them.
|
||||
|
||||
**Incorrect (blocks both branches):**
|
||||
|
||||
```typescript
|
||||
async function handleRequest(userId: string, skipProcessing: boolean) {
|
||||
const userData = await fetchUserData(userId);
|
||||
|
||||
if (skipProcessing) {
|
||||
// Returns immediately but still waited for userData
|
||||
return { skipped: true };
|
||||
}
|
||||
|
||||
// Only this branch uses userData
|
||||
return processUserData(userData);
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (only blocks when needed):**
|
||||
|
||||
```typescript
|
||||
async function handleRequest(userId: string, skipProcessing: boolean) {
|
||||
if (skipProcessing) {
|
||||
// Returns immediately without waiting
|
||||
return { skipped: true };
|
||||
}
|
||||
|
||||
// Fetch only when needed
|
||||
const userData = await fetchUserData(userId);
|
||||
return processUserData(userData);
|
||||
}
|
||||
```
|
||||
|
||||
**Another example (early return optimization):**
|
||||
|
||||
```typescript
|
||||
// Incorrect: always fetches permissions
|
||||
async function updateResource(resourceId: string, userId: string) {
|
||||
const permissions = await fetchPermissions(userId);
|
||||
const resource = await getResource(resourceId);
|
||||
|
||||
if (!resource) {
|
||||
return { error: "Not found" };
|
||||
}
|
||||
|
||||
if (!permissions.canEdit) {
|
||||
return { error: "Forbidden" };
|
||||
}
|
||||
|
||||
return await updateResourceData(resource, permissions);
|
||||
}
|
||||
|
||||
// Correct: fetches only when needed
|
||||
async function updateResource(resourceId: string, userId: string) {
|
||||
const resource = await getResource(resourceId);
|
||||
|
||||
if (!resource) {
|
||||
return { error: "Not found" };
|
||||
}
|
||||
|
||||
const permissions = await fetchPermissions(userId);
|
||||
|
||||
if (!permissions.canEdit) {
|
||||
return { error: "Forbidden" };
|
||||
}
|
||||
|
||||
return await updateResourceData(resource, permissions);
|
||||
}
|
||||
```
|
||||
|
||||
This optimization is especially valuable when the skipped branch is frequently taken, or when the deferred operation is expensive.
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
---
|
||||
title: Dependency-Based Parallelization
|
||||
impact: CRITICAL
|
||||
impactDescription: 2-10× improvement
|
||||
tags: async, parallelization, dependencies, better-all
|
||||
---
|
||||
|
||||
## Dependency-Based Parallelization
|
||||
|
||||
For operations with partial dependencies, use `better-all` to maximize parallelism. It automatically starts each task at the earliest possible moment.
|
||||
|
||||
**Incorrect (profile waits for config unnecessarily):**
|
||||
|
||||
```typescript
|
||||
const [user, config] = await Promise.all([fetchUser(), fetchConfig()]);
|
||||
const profile = await fetchProfile(user.id);
|
||||
```
|
||||
|
||||
**Correct (config and profile run in parallel):**
|
||||
|
||||
```typescript
|
||||
import { all } from "better-all";
|
||||
|
||||
const { user, config, profile } = await all({
|
||||
async user() {
|
||||
return fetchUser();
|
||||
},
|
||||
async config() {
|
||||
return fetchConfig();
|
||||
},
|
||||
async profile() {
|
||||
return fetchProfile((await this.$.user).id);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Alternative without extra dependencies:**
|
||||
|
||||
We can also create all the promises first, and do `Promise.all()` at the end.
|
||||
|
||||
```typescript
|
||||
const userPromise = fetchUser();
|
||||
const profilePromise = userPromise.then((user) => fetchProfile(user.id));
|
||||
|
||||
const [user, config, profile] = await Promise.all([
|
||||
userPromise,
|
||||
fetchConfig(),
|
||||
profilePromise,
|
||||
]);
|
||||
```
|
||||
|
||||
Reference: [https://github.com/shuding/better-all](https://github.com/shuding/better-all)
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
title: Promise.all() for Independent Operations
|
||||
impact: CRITICAL
|
||||
impactDescription: 2-10× improvement
|
||||
tags: async, parallelization, promises, waterfalls
|
||||
---
|
||||
|
||||
## Promise.all() for Independent Operations
|
||||
|
||||
When async operations have no interdependencies, execute them concurrently using `Promise.all()`.
|
||||
|
||||
**Incorrect (sequential execution, 3 round trips):**
|
||||
|
||||
```typescript
|
||||
const user = await fetchUser();
|
||||
const posts = await fetchPosts();
|
||||
const comments = await fetchComments();
|
||||
```
|
||||
|
||||
**Correct (parallel execution, 1 round trip):**
|
||||
|
||||
```typescript
|
||||
const [user, posts, comments] = await Promise.all([
|
||||
fetchUser(),
|
||||
fetchPosts(),
|
||||
fetchComments(),
|
||||
]);
|
||||
```
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
---
|
||||
title: Strategic Suspense Boundaries
|
||||
impact: HIGH
|
||||
impactDescription: faster initial paint
|
||||
tags: async, suspense, streaming, layout-shift
|
||||
---
|
||||
|
||||
## Strategic Suspense Boundaries
|
||||
|
||||
Instead of awaiting data in async components before returning JSX, use Suspense boundaries to show the wrapper UI faster while data loads.
|
||||
|
||||
**Incorrect (wrapper blocked by data fetching):**
|
||||
|
||||
```tsx
|
||||
async function Page() {
|
||||
const data = await fetchData(); // Blocks entire page
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>Sidebar</div>
|
||||
<div>Header</div>
|
||||
<div>
|
||||
<DataDisplay data={data} />
|
||||
</div>
|
||||
<div>Footer</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The entire layout waits for data even though only the middle section needs it.
|
||||
|
||||
**Correct (wrapper shows immediately, data streams in):**
|
||||
|
||||
```tsx
|
||||
function Page() {
|
||||
return (
|
||||
<div>
|
||||
<div>Sidebar</div>
|
||||
<div>Header</div>
|
||||
<div>
|
||||
<Suspense fallback={<Skeleton />}>
|
||||
<DataDisplay />
|
||||
</Suspense>
|
||||
</div>
|
||||
<div>Footer</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function DataDisplay() {
|
||||
const data = await fetchData(); // Only blocks this component
|
||||
return <div>{data.content}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
Sidebar, Header, and Footer render immediately. Only DataDisplay waits for data.
|
||||
|
||||
**Alternative (share promise across components):**
|
||||
|
||||
```tsx
|
||||
function Page() {
|
||||
// Start fetch immediately, but don't await
|
||||
const dataPromise = fetchData();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>Sidebar</div>
|
||||
<div>Header</div>
|
||||
<Suspense fallback={<Skeleton />}>
|
||||
<DataDisplay dataPromise={dataPromise} />
|
||||
<DataSummary dataPromise={dataPromise} />
|
||||
</Suspense>
|
||||
<div>Footer</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataDisplay({ dataPromise }: { dataPromise: Promise<Data> }) {
|
||||
const data = use(dataPromise); // Unwraps the promise
|
||||
return <div>{data.content}</div>;
|
||||
}
|
||||
|
||||
function DataSummary({ dataPromise }: { dataPromise: Promise<Data> }) {
|
||||
const data = use(dataPromise); // Reuses the same promise
|
||||
return <div>{data.summary}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
Both components share the same promise, so only one fetch occurs. Layout renders immediately while both components wait together.
|
||||
|
||||
**When NOT to use this pattern:**
|
||||
|
||||
- Critical data needed for layout decisions (affects positioning)
|
||||
- SEO-critical content above the fold
|
||||
- Small, fast queries where suspense overhead isn't worth it
|
||||
- When you want to avoid layout shift (loading → content jump)
|
||||
|
||||
**Trade-off:** Faster initial paint vs potential layout shift. Choose based on your UX priorities.
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
---
|
||||
title: Avoid Barrel File Imports
|
||||
impact: CRITICAL
|
||||
impactDescription: 200-800ms import cost, slow builds
|
||||
tags: bundle, imports, tree-shaking, barrel-files, performance
|
||||
---
|
||||
|
||||
## Avoid Barrel File Imports
|
||||
|
||||
Import directly from source files instead of barrel files to avoid loading thousands of unused modules. **Barrel files** are entry points that re-export multiple modules (e.g., `index.js` that does `export * from './module'`).
|
||||
|
||||
Popular icon and component libraries can have **up to 10,000 re-exports** in their entry file. For many React packages, **it takes 200-800ms just to import them**, affecting both development speed and production cold starts.
|
||||
|
||||
**Why tree-shaking doesn't help:** When a library is marked as external (not bundled), the bundler can't optimize it. If you bundle it to enable tree-shaking, builds become substantially slower analyzing the entire module graph.
|
||||
|
||||
**Incorrect (imports entire library):**
|
||||
|
||||
```tsx
|
||||
import { Check, X, Menu } from "lucide-react";
|
||||
// Loads 1,583 modules, takes ~2.8s extra in dev
|
||||
// Runtime cost: 200-800ms on every cold start
|
||||
|
||||
import { Button, TextField } from "@mui/material";
|
||||
// Loads 2,225 modules, takes ~4.2s extra in dev
|
||||
```
|
||||
|
||||
**Correct (imports only what you need):**
|
||||
|
||||
```tsx
|
||||
import Check from "lucide-react/dist/esm/icons/check";
|
||||
import X from "lucide-react/dist/esm/icons/x";
|
||||
import Menu from "lucide-react/dist/esm/icons/menu";
|
||||
// Loads only 3 modules (~2KB vs ~1MB)
|
||||
|
||||
import Button from "@mui/material/Button";
|
||||
import TextField from "@mui/material/TextField";
|
||||
// Loads only what you use
|
||||
```
|
||||
|
||||
**Alternative (Next.js 13.5+):**
|
||||
|
||||
```js
|
||||
// next.config.js - use optimizePackageImports
|
||||
module.exports = {
|
||||
experimental: {
|
||||
optimizePackageImports: ["lucide-react", "@mui/material"],
|
||||
},
|
||||
};
|
||||
|
||||
// Then you can keep the ergonomic barrel imports:
|
||||
import { Check, X, Menu } from "lucide-react";
|
||||
// Automatically transformed to direct imports at build time
|
||||
```
|
||||
|
||||
Direct imports provide 15-70% faster dev boot, 28% faster builds, 40% faster cold starts, and significantly faster HMR.
|
||||
|
||||
Libraries commonly affected: `lucide-react`, `@mui/material`, `@mui/icons-material`, `@tabler/icons-react`, `react-icons`, `@headlessui/react`, `@radix-ui/react-*`, `lodash`, `ramda`, `date-fns`, `rxjs`, `react-use`.
|
||||
|
||||
Reference: [How we optimized package imports in Next.js](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js)
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
title: Conditional Module Loading
|
||||
impact: HIGH
|
||||
impactDescription: loads large data only when needed
|
||||
tags: bundle, conditional-loading, lazy-loading
|
||||
---
|
||||
|
||||
## Conditional Module Loading
|
||||
|
||||
Load large data or modules only when a feature is activated.
|
||||
|
||||
**Example (lazy-load animation frames):**
|
||||
|
||||
```tsx
|
||||
function AnimationPlayer({
|
||||
enabled,
|
||||
setEnabled,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
setEnabled: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) {
|
||||
const [frames, setFrames] = useState<Frame[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled && !frames && typeof window !== "undefined") {
|
||||
import("./animation-frames.js")
|
||||
.then((mod) => setFrames(mod.frames))
|
||||
.catch(() => setEnabled(false));
|
||||
}
|
||||
}, [enabled, frames, setEnabled]);
|
||||
|
||||
if (!frames) return <Skeleton />;
|
||||
return <Canvas frames={frames} />;
|
||||
}
|
||||
```
|
||||
|
||||
The `typeof window !== 'undefined'` check prevents bundling this module for SSR, optimizing server bundle size and build speed.
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
---
|
||||
title: Defer Non-Critical Third-Party Libraries
|
||||
impact: MEDIUM
|
||||
impactDescription: loads after hydration
|
||||
tags: bundle, third-party, analytics, defer
|
||||
---
|
||||
|
||||
## Defer Non-Critical Third-Party Libraries
|
||||
|
||||
Analytics, logging, and error tracking don't block user interaction. Load them after hydration.
|
||||
|
||||
**Incorrect (blocks initial bundle):**
|
||||
|
||||
```tsx
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
{children}
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (loads after hydration):**
|
||||
|
||||
```tsx
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const Analytics = dynamic(
|
||||
() => import("@vercel/analytics/react").then((m) => m.Analytics),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
{children}
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
title: Dynamic Imports for Heavy Components
|
||||
impact: CRITICAL
|
||||
impactDescription: directly affects TTI and LCP
|
||||
tags: bundle, dynamic-import, code-splitting, next-dynamic
|
||||
---
|
||||
|
||||
## Dynamic Imports for Heavy Components
|
||||
|
||||
Use `next/dynamic` to lazy-load large components not needed on initial render.
|
||||
|
||||
**Incorrect (Monaco bundles with main chunk ~300KB):**
|
||||
|
||||
```tsx
|
||||
import { MonacoEditor } from "./monaco-editor";
|
||||
|
||||
function CodePanel({ code }: { code: string }) {
|
||||
return <MonacoEditor value={code} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (Monaco loads on demand):**
|
||||
|
||||
```tsx
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const MonacoEditor = dynamic(
|
||||
() => import("./monaco-editor").then((m) => m.MonacoEditor),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
function CodePanel({ code }: { code: string }) {
|
||||
return <MonacoEditor value={code} />;
|
||||
}
|
||||
```
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
---
|
||||
title: Preload Based on User Intent
|
||||
impact: MEDIUM
|
||||
impactDescription: reduces perceived latency
|
||||
tags: bundle, preload, user-intent, hover
|
||||
---
|
||||
|
||||
## Preload Based on User Intent
|
||||
|
||||
Preload heavy bundles before they're needed to reduce perceived latency.
|
||||
|
||||
**Example (preload on hover/focus):**
|
||||
|
||||
```tsx
|
||||
function EditorButton({ onClick }: { onClick: () => void }) {
|
||||
const preload = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
void import("./monaco-editor");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button onMouseEnter={preload} onFocus={preload} onClick={onClick}>
|
||||
Open Editor
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Example (preload when feature flag is enabled):**
|
||||
|
||||
```tsx
|
||||
function FlagsProvider({ children, flags }: Props) {
|
||||
useEffect(() => {
|
||||
if (flags.editorEnabled && typeof window !== "undefined") {
|
||||
void import("./monaco-editor").then((mod) => mod.init());
|
||||
}
|
||||
}, [flags.editorEnabled]);
|
||||
|
||||
return (
|
||||
<FlagsContext.Provider value={flags}>{children}</FlagsContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The `typeof window !== 'undefined'` check prevents bundling preloaded modules for SSR, optimizing server bundle size and build speed.
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
---
|
||||
title: Deduplicate Global Event Listeners
|
||||
impact: LOW
|
||||
impactDescription: single listener for N components
|
||||
tags: client, swr, event-listeners, subscription
|
||||
---
|
||||
|
||||
## Deduplicate Global Event Listeners
|
||||
|
||||
Use `useSWRSubscription()` to share global event listeners across component instances.
|
||||
|
||||
**Incorrect (N instances = N listeners):**
|
||||
|
||||
```tsx
|
||||
function useKeyboardShortcut(key: string, callback: () => void) {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.metaKey && e.key === key) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [key, callback]);
|
||||
}
|
||||
```
|
||||
|
||||
When using the `useKeyboardShortcut` hook multiple times, each instance will register a new listener.
|
||||
|
||||
**Correct (N instances = 1 listener):**
|
||||
|
||||
```tsx
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
|
||||
// Module-level Map to track callbacks per key
|
||||
const keyCallbacks = new Map<string, Set<() => void>>();
|
||||
|
||||
function useKeyboardShortcut(key: string, callback: () => void) {
|
||||
// Register this callback in the Map
|
||||
useEffect(() => {
|
||||
if (!keyCallbacks.has(key)) {
|
||||
keyCallbacks.set(key, new Set());
|
||||
}
|
||||
keyCallbacks.get(key)!.add(callback);
|
||||
|
||||
return () => {
|
||||
const set = keyCallbacks.get(key);
|
||||
if (set) {
|
||||
set.delete(callback);
|
||||
if (set.size === 0) {
|
||||
keyCallbacks.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [key, callback]);
|
||||
|
||||
useSWRSubscription("global-keydown", () => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.metaKey && keyCallbacks.has(e.key)) {
|
||||
keyCallbacks.get(e.key)!.forEach((cb) => cb());
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
});
|
||||
}
|
||||
|
||||
function Profile() {
|
||||
// Multiple shortcuts will share the same listener
|
||||
useKeyboardShortcut("p", () => {
|
||||
/* ... */
|
||||
});
|
||||
useKeyboardShortcut("k", () => {
|
||||
/* ... */
|
||||
});
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
---
|
||||
title: Version and Minimize localStorage Data
|
||||
impact: MEDIUM
|
||||
impactDescription: prevents schema conflicts, reduces storage size
|
||||
tags: client, localStorage, storage, versioning, data-minimization
|
||||
---
|
||||
|
||||
## Version and Minimize localStorage Data
|
||||
|
||||
Add version prefix to keys and store only needed fields. Prevents schema conflicts and accidental storage of sensitive data.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```typescript
|
||||
// No version, stores everything, no error handling
|
||||
localStorage.setItem("userConfig", JSON.stringify(fullUserObject));
|
||||
const data = localStorage.getItem("userConfig");
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```typescript
|
||||
const VERSION = "v2";
|
||||
|
||||
function saveConfig(config: { theme: string; language: string }) {
|
||||
try {
|
||||
localStorage.setItem(`userConfig:${VERSION}`, JSON.stringify(config));
|
||||
} catch {
|
||||
// Throws in incognito/private browsing, quota exceeded, or disabled
|
||||
}
|
||||
}
|
||||
|
||||
function loadConfig() {
|
||||
try {
|
||||
const data = localStorage.getItem(`userConfig:${VERSION}`);
|
||||
return data ? JSON.parse(data) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Migration from v1 to v2
|
||||
function migrate() {
|
||||
try {
|
||||
const v1 = localStorage.getItem("userConfig:v1");
|
||||
if (v1) {
|
||||
const old = JSON.parse(v1);
|
||||
saveConfig({
|
||||
theme: old.darkMode ? "dark" : "light",
|
||||
language: old.lang,
|
||||
});
|
||||
localStorage.removeItem("userConfig:v1");
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
```
|
||||
|
||||
**Store minimal fields from server responses:**
|
||||
|
||||
```typescript
|
||||
// User object has 20+ fields, only store what UI needs
|
||||
function cachePrefs(user: FullUser) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
"prefs:v1",
|
||||
JSON.stringify({
|
||||
theme: user.preferences.theme,
|
||||
notifications: user.preferences.notifications,
|
||||
}),
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
```
|
||||
|
||||
**Always wrap in try-catch:** `getItem()` and `setItem()` throw in incognito/private browsing (Safari, Firefox), when quota exceeded, or when disabled.
|
||||
|
||||
**Benefits:** Schema evolution via versioning, reduced storage size, prevents storing tokens/PII/internal flags.
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
---
|
||||
title: Use Passive Event Listeners for Scrolling Performance
|
||||
impact: MEDIUM
|
||||
impactDescription: eliminates scroll delay caused by event listeners
|
||||
tags: client, event-listeners, scrolling, performance, touch, wheel
|
||||
---
|
||||
|
||||
## Use Passive Event Listeners for Scrolling Performance
|
||||
|
||||
Add `{ passive: true }` to touch and wheel event listeners to enable immediate scrolling. Browsers normally wait for listeners to finish to check if `preventDefault()` is called, causing scroll delay.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX);
|
||||
const handleWheel = (e: WheelEvent) => console.log(e.deltaY);
|
||||
|
||||
document.addEventListener("touchstart", handleTouch);
|
||||
document.addEventListener("wheel", handleWheel);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("touchstart", handleTouch);
|
||||
document.removeEventListener("wheel", handleWheel);
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX);
|
||||
const handleWheel = (e: WheelEvent) => console.log(e.deltaY);
|
||||
|
||||
document.addEventListener("touchstart", handleTouch, { passive: true });
|
||||
document.addEventListener("wheel", handleWheel, { passive: true });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("touchstart", handleTouch);
|
||||
document.removeEventListener("wheel", handleWheel);
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
**Use passive when:** tracking/analytics, logging, any listener that doesn't call `preventDefault()`.
|
||||
|
||||
**Don't use passive when:** implementing custom swipe gestures, custom zoom controls, or any listener that needs `preventDefault()`.
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
---
|
||||
title: Use SWR for Automatic Deduplication
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: automatic deduplication
|
||||
tags: client, swr, deduplication, data-fetching
|
||||
---
|
||||
|
||||
## Use SWR for Automatic Deduplication
|
||||
|
||||
SWR enables request deduplication, caching, and revalidation across component instances.
|
||||
|
||||
**Incorrect (no deduplication, each instance fetches):**
|
||||
|
||||
```tsx
|
||||
function UserList() {
|
||||
const [users, setUsers] = useState([]);
|
||||
useEffect(() => {
|
||||
fetch("/api/users")
|
||||
.then((r) => r.json())
|
||||
.then(setUsers);
|
||||
}, []);
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (multiple instances share one request):**
|
||||
|
||||
```tsx
|
||||
import useSWR from "swr";
|
||||
|
||||
function UserList() {
|
||||
const { data: users } = useSWR("/api/users", fetcher);
|
||||
}
|
||||
```
|
||||
|
||||
**For immutable data:**
|
||||
|
||||
```tsx
|
||||
import { useImmutableSWR } from "@/lib/swr";
|
||||
|
||||
function StaticContent() {
|
||||
const { data } = useImmutableSWR("/api/config", fetcher);
|
||||
}
|
||||
```
|
||||
|
||||
**For mutations:**
|
||||
|
||||
```tsx
|
||||
import { useSWRMutation } from "swr/mutation";
|
||||
|
||||
function UpdateButton() {
|
||||
const { trigger } = useSWRMutation("/api/user", updateUser);
|
||||
return <button onClick={() => trigger()}>Update</button>;
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [https://swr.vercel.app](https://swr.vercel.app)
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
---
|
||||
title: Avoid Layout Thrashing
|
||||
impact: MEDIUM
|
||||
impactDescription: prevents forced synchronous layouts and reduces performance bottlenecks
|
||||
tags: javascript, dom, css, performance, reflow, layout-thrashing
|
||||
---
|
||||
|
||||
## Avoid Layout Thrashing
|
||||
|
||||
Avoid interleaving style writes with layout reads. When you read a layout property (like `offsetWidth`, `getBoundingClientRect()`, or `getComputedStyle()`) between style changes, the browser is forced to trigger a synchronous reflow.
|
||||
|
||||
**This is OK (browser batches style changes):**
|
||||
|
||||
```typescript
|
||||
function updateElementStyles(element: HTMLElement) {
|
||||
// Each line invalidates style, but browser batches the recalculation
|
||||
element.style.width = "100px";
|
||||
element.style.height = "200px";
|
||||
element.style.backgroundColor = "blue";
|
||||
element.style.border = "1px solid black";
|
||||
}
|
||||
```
|
||||
|
||||
**Incorrect (interleaved reads and writes force reflows):**
|
||||
|
||||
```typescript
|
||||
function layoutThrashing(element: HTMLElement) {
|
||||
element.style.width = "100px";
|
||||
const width = element.offsetWidth; // Forces reflow
|
||||
element.style.height = "200px";
|
||||
const height = element.offsetHeight; // Forces another reflow
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (batch writes, then read once):**
|
||||
|
||||
```typescript
|
||||
function updateElementStyles(element: HTMLElement) {
|
||||
// Batch all writes together
|
||||
element.style.width = "100px";
|
||||
element.style.height = "200px";
|
||||
element.style.backgroundColor = "blue";
|
||||
element.style.border = "1px solid black";
|
||||
|
||||
// Read after all writes are done (single reflow)
|
||||
const { width, height } = element.getBoundingClientRect();
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (batch reads, then writes):**
|
||||
|
||||
```typescript
|
||||
function avoidThrashing(element: HTMLElement) {
|
||||
// Read phase - all layout queries first
|
||||
const rect1 = element.getBoundingClientRect();
|
||||
const offsetWidth = element.offsetWidth;
|
||||
const offsetHeight = element.offsetHeight;
|
||||
|
||||
// Write phase - all style changes after
|
||||
element.style.width = "100px";
|
||||
element.style.height = "200px";
|
||||
}
|
||||
```
|
||||
|
||||
**Better: use CSS classes**
|
||||
|
||||
```css
|
||||
.highlighted-box {
|
||||
width: 100px;
|
||||
height: 200px;
|
||||
background-color: blue;
|
||||
border: 1px solid black;
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
function updateElementStyles(element: HTMLElement) {
|
||||
element.classList.add("highlighted-box");
|
||||
|
||||
const { width, height } = element.getBoundingClientRect();
|
||||
}
|
||||
```
|
||||
|
||||
**React example:**
|
||||
|
||||
```tsx
|
||||
// Incorrect: interleaving style changes with layout queries
|
||||
function Box({ isHighlighted }: { isHighlighted: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current && isHighlighted) {
|
||||
ref.current.style.width = "100px";
|
||||
const width = ref.current.offsetWidth; // Forces layout
|
||||
ref.current.style.height = "200px";
|
||||
}
|
||||
}, [isHighlighted]);
|
||||
|
||||
return <div ref={ref}>Content</div>;
|
||||
}
|
||||
|
||||
// Correct: toggle class
|
||||
function Box({ isHighlighted }: { isHighlighted: boolean }) {
|
||||
return <div className={isHighlighted ? "highlighted-box" : ""}>Content</div>;
|
||||
}
|
||||
```
|
||||
|
||||
Prefer CSS classes over inline styles when possible. CSS files are cached by the browser, and classes provide better separation of concerns and are easier to maintain.
|
||||
|
||||
See [this gist](https://gist.github.com/paulirish/5d52fb081b3570c81e3a) and [CSS Triggers](https://csstriggers.com/) for more information on layout-forcing operations.
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
---
|
||||
title: Cache Repeated Function Calls
|
||||
impact: MEDIUM
|
||||
impactDescription: avoid redundant computation
|
||||
tags: javascript, cache, memoization, performance
|
||||
---
|
||||
|
||||
## Cache Repeated Function Calls
|
||||
|
||||
Use a module-level Map to cache function results when the same function is called repeatedly with the same inputs during render.
|
||||
|
||||
**Incorrect (redundant computation):**
|
||||
|
||||
```typescript
|
||||
function ProjectList({ projects }: { projects: Project[] }) {
|
||||
return (
|
||||
<div>
|
||||
{projects.map(project => {
|
||||
// slugify() called 100+ times for same project names
|
||||
const slug = slugify(project.name)
|
||||
|
||||
return <ProjectCard key={project.id} slug={slug} />
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (cached results):**
|
||||
|
||||
```typescript
|
||||
// Module-level cache
|
||||
const slugifyCache = new Map<string, string>()
|
||||
|
||||
function cachedSlugify(text: string): string {
|
||||
if (slugifyCache.has(text)) {
|
||||
return slugifyCache.get(text)!
|
||||
}
|
||||
const result = slugify(text)
|
||||
slugifyCache.set(text, result)
|
||||
return result
|
||||
}
|
||||
|
||||
function ProjectList({ projects }: { projects: Project[] }) {
|
||||
return (
|
||||
<div>
|
||||
{projects.map(project => {
|
||||
// Computed only once per unique project name
|
||||
const slug = cachedSlugify(project.name)
|
||||
|
||||
return <ProjectCard key={project.id} slug={slug} />
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Simpler pattern for single-value functions:**
|
||||
|
||||
```typescript
|
||||
let isLoggedInCache: boolean | null = null;
|
||||
|
||||
function isLoggedIn(): boolean {
|
||||
if (isLoggedInCache !== null) {
|
||||
return isLoggedInCache;
|
||||
}
|
||||
|
||||
isLoggedInCache = document.cookie.includes("auth=");
|
||||
return isLoggedInCache;
|
||||
}
|
||||
|
||||
// Clear cache when auth changes
|
||||
function onAuthChange() {
|
||||
isLoggedInCache = null;
|
||||
}
|
||||
```
|
||||
|
||||
Use a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components.
|
||||
|
||||
Reference: [How we made the Vercel Dashboard twice as fast](https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast)
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
title: Cache Property Access in Loops
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: reduces lookups
|
||||
tags: javascript, loops, optimization, caching
|
||||
---
|
||||
|
||||
## Cache Property Access in Loops
|
||||
|
||||
Cache object property lookups in hot paths.
|
||||
|
||||
**Incorrect (3 lookups × N iterations):**
|
||||
|
||||
```typescript
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
process(obj.config.settings.value);
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (1 lookup total):**
|
||||
|
||||
```typescript
|
||||
const value = obj.config.settings.value;
|
||||
const len = arr.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
process(value);
|
||||
}
|
||||
```
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
---
|
||||
title: Cache Storage API Calls
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: reduces expensive I/O
|
||||
tags: javascript, localStorage, storage, caching, performance
|
||||
---
|
||||
|
||||
## Cache Storage API Calls
|
||||
|
||||
`localStorage`, `sessionStorage`, and `document.cookie` are synchronous and expensive. Cache reads in memory.
|
||||
|
||||
**Incorrect (reads storage on every call):**
|
||||
|
||||
```typescript
|
||||
function getTheme() {
|
||||
return localStorage.getItem("theme") ?? "light";
|
||||
}
|
||||
// Called 10 times = 10 storage reads
|
||||
```
|
||||
|
||||
**Correct (Map cache):**
|
||||
|
||||
```typescript
|
||||
const storageCache = new Map<string, string | null>();
|
||||
|
||||
function getLocalStorage(key: string) {
|
||||
if (!storageCache.has(key)) {
|
||||
storageCache.set(key, localStorage.getItem(key));
|
||||
}
|
||||
return storageCache.get(key);
|
||||
}
|
||||
|
||||
function setLocalStorage(key: string, value: string) {
|
||||
localStorage.setItem(key, value);
|
||||
storageCache.set(key, value); // keep cache in sync
|
||||
}
|
||||
```
|
||||
|
||||
Use a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components.
|
||||
|
||||
**Cookie caching:**
|
||||
|
||||
```typescript
|
||||
let cookieCache: Record<string, string> | null = null;
|
||||
|
||||
function getCookie(name: string) {
|
||||
if (!cookieCache) {
|
||||
cookieCache = Object.fromEntries(
|
||||
document.cookie.split("; ").map((c) => c.split("=")),
|
||||
);
|
||||
}
|
||||
return cookieCache[name];
|
||||
}
|
||||
```
|
||||
|
||||
**Important (invalidate on external changes):**
|
||||
|
||||
If storage can change externally (another tab, server-set cookies), invalidate cache:
|
||||
|
||||
```typescript
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key) storageCache.delete(e.key);
|
||||
});
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
storageCache.clear();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
title: Combine Multiple Array Iterations
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: reduces iterations
|
||||
tags: javascript, arrays, loops, performance
|
||||
---
|
||||
|
||||
## Combine Multiple Array Iterations
|
||||
|
||||
Multiple `.filter()` or `.map()` calls iterate the array multiple times. Combine into one loop.
|
||||
|
||||
**Incorrect (3 iterations):**
|
||||
|
||||
```typescript
|
||||
const admins = users.filter((u) => u.isAdmin);
|
||||
const testers = users.filter((u) => u.isTester);
|
||||
const inactive = users.filter((u) => !u.isActive);
|
||||
```
|
||||
|
||||
**Correct (1 iteration):**
|
||||
|
||||
```typescript
|
||||
const admins: User[] = [];
|
||||
const testers: User[] = [];
|
||||
const inactive: User[] = [];
|
||||
|
||||
for (const user of users) {
|
||||
if (user.isAdmin) admins.push(user);
|
||||
if (user.isTester) testers.push(user);
|
||||
if (!user.isActive) inactive.push(user);
|
||||
}
|
||||
```
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
title: Early Return from Functions
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: avoids unnecessary computation
|
||||
tags: javascript, functions, optimization, early-return
|
||||
---
|
||||
|
||||
## Early Return from Functions
|
||||
|
||||
Return early when result is determined to skip unnecessary processing.
|
||||
|
||||
**Incorrect (processes all items even after finding answer):**
|
||||
|
||||
```typescript
|
||||
function validateUsers(users: User[]) {
|
||||
let hasError = false;
|
||||
let errorMessage = "";
|
||||
|
||||
for (const user of users) {
|
||||
if (!user.email) {
|
||||
hasError = true;
|
||||
errorMessage = "Email required";
|
||||
}
|
||||
if (!user.name) {
|
||||
hasError = true;
|
||||
errorMessage = "Name required";
|
||||
}
|
||||
// Continues checking all users even after error found
|
||||
}
|
||||
|
||||
return hasError ? { valid: false, error: errorMessage } : { valid: true };
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (returns immediately on first error):**
|
||||
|
||||
```typescript
|
||||
function validateUsers(users: User[]) {
|
||||
for (const user of users) {
|
||||
if (!user.email) {
|
||||
return { valid: false, error: "Email required" };
|
||||
}
|
||||
if (!user.name) {
|
||||
return { valid: false, error: "Name required" };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
```
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
---
|
||||
title: Hoist RegExp Creation
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: avoids recreation
|
||||
tags: javascript, regexp, optimization, memoization
|
||||
---
|
||||
|
||||
## Hoist RegExp Creation
|
||||
|
||||
Don't create RegExp inside render. Hoist to module scope or memoize with `useMemo()`.
|
||||
|
||||
**Incorrect (new RegExp every render):**
|
||||
|
||||
```tsx
|
||||
function Highlighter({ text, query }: Props) {
|
||||
const regex = new RegExp(`(${query})`, 'gi')
|
||||
const parts = text.split(regex)
|
||||
return <>{parts.map((part, i) => ...)}</>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (memoize or hoist):**
|
||||
|
||||
```tsx
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
function Highlighter({ text, query }: Props) {
|
||||
const regex = useMemo(
|
||||
() => new RegExp(`(${escapeRegex(query)})`, 'gi'),
|
||||
[query]
|
||||
)
|
||||
const parts = text.split(regex)
|
||||
return <>{parts.map((part, i) => ...)}</>
|
||||
}
|
||||
```
|
||||
|
||||
**Warning (global regex has mutable state):**
|
||||
|
||||
Global regex (`/g`) has mutable `lastIndex` state:
|
||||
|
||||
```typescript
|
||||
const regex = /foo/g;
|
||||
regex.test("foo"); // true, lastIndex = 3
|
||||
regex.test("foo"); // false, lastIndex = 0
|
||||
```
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
title: Build Index Maps for Repeated Lookups
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: 1M ops to 2K ops
|
||||
tags: javascript, map, indexing, optimization, performance
|
||||
---
|
||||
|
||||
## Build Index Maps for Repeated Lookups
|
||||
|
||||
Multiple `.find()` calls by the same key should use a Map.
|
||||
|
||||
**Incorrect (O(n) per lookup):**
|
||||
|
||||
```typescript
|
||||
function processOrders(orders: Order[], users: User[]) {
|
||||
return orders.map((order) => ({
|
||||
...order,
|
||||
user: users.find((u) => u.id === order.userId),
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (O(1) per lookup):**
|
||||
|
||||
```typescript
|
||||
function processOrders(orders: Order[], users: User[]) {
|
||||
const userById = new Map(users.map((u) => [u.id, u]));
|
||||
|
||||
return orders.map((order) => ({
|
||||
...order,
|
||||
user: userById.get(order.userId),
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
Build map once (O(n)), then all lookups are O(1).
|
||||
For 1000 orders × 1000 users: 1M ops → 2K ops.
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
title: Early Length Check for Array Comparisons
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: avoids expensive operations when lengths differ
|
||||
tags: javascript, arrays, performance, optimization, comparison
|
||||
---
|
||||
|
||||
## Early Length Check for Array Comparisons
|
||||
|
||||
When comparing arrays with expensive operations (sorting, deep equality, serialization), check lengths first. If lengths differ, the arrays cannot be equal.
|
||||
|
||||
In real-world applications, this optimization is especially valuable when the comparison runs in hot paths (event handlers, render loops).
|
||||
|
||||
**Incorrect (always runs expensive comparison):**
|
||||
|
||||
```typescript
|
||||
function hasChanges(current: string[], original: string[]) {
|
||||
// Always sorts and joins, even when lengths differ
|
||||
return current.sort().join() !== original.sort().join();
|
||||
}
|
||||
```
|
||||
|
||||
Two O(n log n) sorts run even when `current.length` is 5 and `original.length` is 100. There is also overhead of joining the arrays and comparing the strings.
|
||||
|
||||
**Correct (O(1) length check first):**
|
||||
|
||||
```typescript
|
||||
function hasChanges(current: string[], original: string[]) {
|
||||
// Early return if lengths differ
|
||||
if (current.length !== original.length) {
|
||||
return true;
|
||||
}
|
||||
// Only sort when lengths match
|
||||
const currentSorted = current.toSorted();
|
||||
const originalSorted = original.toSorted();
|
||||
for (let i = 0; i < currentSorted.length; i++) {
|
||||
if (currentSorted[i] !== originalSorted[i]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
This new approach is more efficient because:
|
||||
|
||||
- It avoids the overhead of sorting and joining the arrays when lengths differ
|
||||
- It avoids consuming memory for the joined strings (especially important for large arrays)
|
||||
- It avoids mutating the original arrays
|
||||
- It returns early when a difference is found
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
---
|
||||
title: Use Loop for Min/Max Instead of Sort
|
||||
impact: LOW
|
||||
impactDescription: O(n) instead of O(n log n)
|
||||
tags: javascript, arrays, performance, sorting, algorithms
|
||||
---
|
||||
|
||||
## Use Loop for Min/Max Instead of Sort
|
||||
|
||||
Finding the smallest or largest element only requires a single pass through the array. Sorting is wasteful and slower.
|
||||
|
||||
**Incorrect (O(n log n) - sort to find latest):**
|
||||
|
||||
```typescript
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
function getLatestProject(projects: Project[]) {
|
||||
const sorted = [...projects].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
return sorted[0];
|
||||
}
|
||||
```
|
||||
|
||||
Sorts the entire array just to find the maximum value.
|
||||
|
||||
**Incorrect (O(n log n) - sort for oldest and newest):**
|
||||
|
||||
```typescript
|
||||
function getOldestAndNewest(projects: Project[]) {
|
||||
const sorted = [...projects].sort((a, b) => a.updatedAt - b.updatedAt);
|
||||
return { oldest: sorted[0], newest: sorted[sorted.length - 1] };
|
||||
}
|
||||
```
|
||||
|
||||
Still sorts unnecessarily when only min/max are needed.
|
||||
|
||||
**Correct (O(n) - single loop):**
|
||||
|
||||
```typescript
|
||||
function getLatestProject(projects: Project[]) {
|
||||
if (projects.length === 0) return null;
|
||||
|
||||
let latest = projects[0];
|
||||
|
||||
for (let i = 1; i < projects.length; i++) {
|
||||
if (projects[i].updatedAt > latest.updatedAt) {
|
||||
latest = projects[i];
|
||||
}
|
||||
}
|
||||
|
||||
return latest;
|
||||
}
|
||||
|
||||
function getOldestAndNewest(projects: Project[]) {
|
||||
if (projects.length === 0) return { oldest: null, newest: null };
|
||||
|
||||
let oldest = projects[0];
|
||||
let newest = projects[0];
|
||||
|
||||
for (let i = 1; i < projects.length; i++) {
|
||||
if (projects[i].updatedAt < oldest.updatedAt) oldest = projects[i];
|
||||
if (projects[i].updatedAt > newest.updatedAt) newest = projects[i];
|
||||
}
|
||||
|
||||
return { oldest, newest };
|
||||
}
|
||||
```
|
||||
|
||||
Single pass through the array, no copying, no sorting.
|
||||
|
||||
**Alternative (Math.min/Math.max for small arrays):**
|
||||
|
||||
```typescript
|
||||
const numbers = [5, 2, 8, 1, 9];
|
||||
const min = Math.min(...numbers);
|
||||
const max = Math.max(...numbers);
|
||||
```
|
||||
|
||||
This works for small arrays, but can be slower or just throw an error for very large arrays due to spread operator limitations. Maximal array length is approximately 124000 in Chrome 143 and 638000 in Safari 18; exact numbers may vary - see [the fiddle](https://jsfiddle.net/qw1jabsx/4/). Use the loop approach for reliability.
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
title: Use Set/Map for O(1) Lookups
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: O(n) to O(1)
|
||||
tags: javascript, set, map, data-structures, performance
|
||||
---
|
||||
|
||||
## Use Set/Map for O(1) Lookups
|
||||
|
||||
Convert arrays to Set/Map for repeated membership checks.
|
||||
|
||||
**Incorrect (O(n) per check):**
|
||||
|
||||
```typescript
|
||||
const allowedIds = ['a', 'b', 'c', ...]
|
||||
items.filter(item => allowedIds.includes(item.id))
|
||||
```
|
||||
|
||||
**Correct (O(1) per check):**
|
||||
|
||||
```typescript
|
||||
const allowedIds = new Set(['a', 'b', 'c', ...])
|
||||
items.filter(item => allowedIds.has(item.id))
|
||||
```
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
---
|
||||
title: Use toSorted() Instead of sort() for Immutability
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: prevents mutation bugs in React state
|
||||
tags: javascript, arrays, immutability, react, state, mutation
|
||||
---
|
||||
|
||||
## Use toSorted() Instead of sort() for Immutability
|
||||
|
||||
`.sort()` mutates the array in place, which can cause bugs with React state and props. Use `.toSorted()` to create a new sorted array without mutation.
|
||||
|
||||
**Incorrect (mutates original array):**
|
||||
|
||||
```typescript
|
||||
function UserList({ users }: { users: User[] }) {
|
||||
// Mutates the users prop array!
|
||||
const sorted = useMemo(
|
||||
() => users.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[users]
|
||||
)
|
||||
return <div>{sorted.map(renderUser)}</div>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (creates new array):**
|
||||
|
||||
```typescript
|
||||
function UserList({ users }: { users: User[] }) {
|
||||
// Creates new sorted array, original unchanged
|
||||
const sorted = useMemo(
|
||||
() => users.toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||
[users]
|
||||
)
|
||||
return <div>{sorted.map(renderUser)}</div>
|
||||
}
|
||||
```
|
||||
|
||||
**Why this matters in React:**
|
||||
|
||||
1. Props/state mutations break React's immutability model - React expects props and state to be treated as read-only
|
||||
2. Causes stale closure bugs - Mutating arrays inside closures (callbacks, effects) can lead to unexpected behavior
|
||||
|
||||
**Browser support (fallback for older browsers):**
|
||||
|
||||
`.toSorted()` is available in all modern browsers (Chrome 110+, Safari 16+, Firefox 115+, Node.js 20+). For older environments, use spread operator:
|
||||
|
||||
```typescript
|
||||
// Fallback for older browsers
|
||||
const sorted = [...items].sort((a, b) => a.value - b.value);
|
||||
```
|
||||
|
||||
**Other immutable array methods:**
|
||||
|
||||
- `.toSorted()` - immutable sort
|
||||
- `.toReversed()` - immutable reverse
|
||||
- `.toSpliced()` - immutable splice
|
||||
- `.with()` - immutable element replacement
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
title: Use Activity Component for Show/Hide
|
||||
impact: MEDIUM
|
||||
impactDescription: preserves state/DOM
|
||||
tags: rendering, activity, visibility, state-preservation
|
||||
---
|
||||
|
||||
## Use Activity Component for Show/Hide
|
||||
|
||||
Use React's `<Activity>` to preserve state/DOM for expensive components that frequently toggle visibility.
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
import { Activity } from "react";
|
||||
|
||||
function Dropdown({ isOpen }: Props) {
|
||||
return (
|
||||
<Activity mode={isOpen ? "visible" : "hidden"}>
|
||||
<ExpensiveMenu />
|
||||
</Activity>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Avoids expensive re-renders and state loss.
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
title: Animate SVG Wrapper Instead of SVG Element
|
||||
impact: LOW
|
||||
impactDescription: enables hardware acceleration
|
||||
tags: rendering, svg, css, animation, performance
|
||||
---
|
||||
|
||||
## Animate SVG Wrapper Instead of SVG Element
|
||||
|
||||
Many browsers don't have hardware acceleration for CSS3 animations on SVG elements. Wrap SVG in a `<div>` and animate the wrapper instead.
|
||||
|
||||
**Incorrect (animating SVG directly - no hardware acceleration):**
|
||||
|
||||
```tsx
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<svg className="animate-spin" width="24" height="24" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (animating wrapper div - hardware accelerated):**
|
||||
|
||||
```tsx
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<div className="animate-spin">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
This applies to all CSS transforms and transitions (`transform`, `opacity`, `translate`, `scale`, `rotate`). The wrapper div allows browsers to use GPU acceleration for smoother animations.
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
title: Use Explicit Conditional Rendering
|
||||
impact: LOW
|
||||
impactDescription: prevents rendering 0 or NaN
|
||||
tags: rendering, conditional, jsx, falsy-values
|
||||
---
|
||||
|
||||
## Use Explicit Conditional Rendering
|
||||
|
||||
Use explicit ternary operators (`? :`) instead of `&&` for conditional rendering when the condition can be `0`, `NaN`, or other falsy values that render.
|
||||
|
||||
**Incorrect (renders "0" when count is 0):**
|
||||
|
||||
```tsx
|
||||
function Badge({ count }: { count: number }) {
|
||||
return <div>{count && <span className="badge">{count}</span>}</div>;
|
||||
}
|
||||
|
||||
// When count = 0, renders: <div>0</div>
|
||||
// When count = 5, renders: <div><span class="badge">5</span></div>
|
||||
```
|
||||
|
||||
**Correct (renders nothing when count is 0):**
|
||||
|
||||
```tsx
|
||||
function Badge({ count }: { count: number }) {
|
||||
return <div>{count > 0 ? <span className="badge">{count}</span> : null}</div>;
|
||||
}
|
||||
|
||||
// When count = 0, renders: <div></div>
|
||||
// When count = 5, renders: <div><span class="badge">5</span></div>
|
||||
```
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
title: CSS content-visibility for Long Lists
|
||||
impact: HIGH
|
||||
impactDescription: faster initial render
|
||||
tags: rendering, css, content-visibility, long-lists
|
||||
---
|
||||
|
||||
## CSS content-visibility for Long Lists
|
||||
|
||||
Apply `content-visibility: auto` to defer off-screen rendering.
|
||||
|
||||
**CSS:**
|
||||
|
||||
```css
|
||||
.message-item {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 0 80px;
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```tsx
|
||||
function MessageList({ messages }: { messages: Message[] }) {
|
||||
return (
|
||||
<div className="h-screen overflow-y-auto">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className="message-item">
|
||||
<Avatar user={msg.author} />
|
||||
<div>{msg.content}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
For 1000 messages, browser skips layout/paint for ~990 off-screen items (10× faster initial render).
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
title: Hoist Static JSX Elements
|
||||
impact: LOW
|
||||
impactDescription: avoids re-creation
|
||||
tags: rendering, jsx, static, optimization
|
||||
---
|
||||
|
||||
## Hoist Static JSX Elements
|
||||
|
||||
Extract static JSX outside components to avoid re-creation.
|
||||
|
||||
**Incorrect (recreates element every render):**
|
||||
|
||||
```tsx
|
||||
function LoadingSkeleton() {
|
||||
return <div className="h-20 animate-pulse bg-gray-200" />;
|
||||
}
|
||||
|
||||
function Container() {
|
||||
return <div>{loading && <LoadingSkeleton />}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (reuses same element):**
|
||||
|
||||
```tsx
|
||||
const loadingSkeleton = <div className="h-20 animate-pulse bg-gray-200" />;
|
||||
|
||||
function Container() {
|
||||
return <div>{loading && loadingSkeleton}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
This is especially helpful for large and static SVG nodes, which can be expensive to recreate on every render.
|
||||
|
||||
**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler automatically hoists static JSX elements and optimizes component re-renders, making manual hoisting unnecessary.
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
---
|
||||
title: Prevent Hydration Mismatch Without Flickering
|
||||
impact: MEDIUM
|
||||
impactDescription: avoids visual flicker and hydration errors
|
||||
tags: rendering, ssr, hydration, localStorage, flicker
|
||||
---
|
||||
|
||||
## Prevent Hydration Mismatch Without Flickering
|
||||
|
||||
When rendering content that depends on client-side storage (localStorage, cookies), avoid both SSR breakage and post-hydration flickering by injecting a synchronous script that updates the DOM before React hydrates.
|
||||
|
||||
**Incorrect (breaks SSR):**
|
||||
|
||||
```tsx
|
||||
function ThemeWrapper({ children }: { children: ReactNode }) {
|
||||
// localStorage is not available on server - throws error
|
||||
const theme = localStorage.getItem("theme") || "light";
|
||||
|
||||
return <div className={theme}>{children}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
Server-side rendering will fail because `localStorage` is undefined.
|
||||
|
||||
**Incorrect (visual flickering):**
|
||||
|
||||
```tsx
|
||||
function ThemeWrapper({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useState("light");
|
||||
|
||||
useEffect(() => {
|
||||
// Runs after hydration - causes visible flash
|
||||
const stored = localStorage.getItem("theme");
|
||||
if (stored) {
|
||||
setTheme(stored);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <div className={theme}>{children}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
Component first renders with default value (`light`), then updates after hydration, causing a visible flash of incorrect content.
|
||||
|
||||
**Correct (no flicker, no hydration mismatch):**
|
||||
|
||||
```tsx
|
||||
function ThemeWrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<div id="theme-wrapper">{children}</div>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
try {
|
||||
var theme = localStorage.getItem('theme') || 'light';
|
||||
var el = document.getElementById('theme-wrapper');
|
||||
if (el) el.className = theme;
|
||||
} catch (e) {}
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The inline script executes synchronously before showing the element, ensuring the DOM already has the correct value. No flickering, no hydration mismatch.
|
||||
|
||||
This pattern is especially useful for theme toggles, user preferences, authentication states, and any client-only data that should render immediately without flashing default values.
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
title: Suppress Expected Hydration Mismatches
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: avoids noisy hydration warnings for known differences
|
||||
tags: rendering, hydration, ssr, nextjs
|
||||
---
|
||||
|
||||
## Suppress Expected Hydration Mismatches
|
||||
|
||||
In SSR frameworks (e.g., Next.js), some values are intentionally different on server vs client (random IDs, dates, locale/timezone formatting). For these _expected_ mismatches, wrap the dynamic text in an element with `suppressHydrationWarning` to prevent noisy warnings. Do not use this to hide real bugs. Don’t overuse it.
|
||||
|
||||
**Incorrect (known mismatch warnings):**
|
||||
|
||||
```tsx
|
||||
function Timestamp() {
|
||||
return <span>{new Date().toLocaleString()}</span>;
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (suppress expected mismatch only):**
|
||||
|
||||
```tsx
|
||||
function Timestamp() {
|
||||
return <span suppressHydrationWarning>{new Date().toLocaleString()}</span>;
|
||||
}
|
||||
```
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
title: Optimize SVG Precision
|
||||
impact: LOW
|
||||
impactDescription: reduces file size
|
||||
tags: rendering, svg, optimization, svgo
|
||||
---
|
||||
|
||||
## Optimize SVG Precision
|
||||
|
||||
Reduce SVG coordinate precision to decrease file size. The optimal precision depends on the viewBox size, but in general reducing precision should be considered.
|
||||
|
||||
**Incorrect (excessive precision):**
|
||||
|
||||
```svg
|
||||
<path d="M 10.293847 20.847362 L 30.938472 40.192837" />
|
||||
```
|
||||
|
||||
**Correct (1 decimal place):**
|
||||
|
||||
```svg
|
||||
<path d="M 10.3 20.8 L 30.9 40.2" />
|
||||
```
|
||||
|
||||
**Automate with SVGO:**
|
||||
|
||||
```bash
|
||||
npx svgo --precision=1 --multipass icon.svg
|
||||
```
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
---
|
||||
title: Use useTransition Over Manual Loading States
|
||||
impact: LOW
|
||||
impactDescription: reduces re-renders and improves code clarity
|
||||
tags: rendering, transitions, useTransition, loading, state
|
||||
---
|
||||
|
||||
## Use useTransition Over Manual Loading States
|
||||
|
||||
Use `useTransition` instead of manual `useState` for loading states. This provides built-in `isPending` state and automatically manages transitions.
|
||||
|
||||
**Incorrect (manual loading state):**
|
||||
|
||||
```tsx
|
||||
function SearchResults() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSearch = async (value: string) => {
|
||||
setIsLoading(true);
|
||||
setQuery(value);
|
||||
const data = await fetchResults(value);
|
||||
setResults(data);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<input onChange={(e) => handleSearch(e.target.value)} />
|
||||
{isLoading && <Spinner />}
|
||||
<ResultsList results={results} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (useTransition with built-in pending state):**
|
||||
|
||||
```tsx
|
||||
import { useTransition, useState } from "react";
|
||||
|
||||
function SearchResults() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState([]);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setQuery(value); // Update input immediately
|
||||
|
||||
startTransition(async () => {
|
||||
// Fetch and update results
|
||||
const data = await fetchResults(value);
|
||||
setResults(data);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<input onChange={(e) => handleSearch(e.target.value)} />
|
||||
{isPending && <Spinner />}
|
||||
<ResultsList results={results} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- **Automatic pending state**: No need to manually manage `setIsLoading(true/false)`
|
||||
- **Error resilience**: Pending state correctly resets even if the transition throws
|
||||
- **Better responsiveness**: Keeps the UI responsive during updates
|
||||
- **Interrupt handling**: New transitions automatically cancel pending ones
|
||||
|
||||
Reference: [useTransition](https://react.dev/reference/react/useTransition)
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
title: Defer State Reads to Usage Point
|
||||
impact: MEDIUM
|
||||
impactDescription: avoids unnecessary subscriptions
|
||||
tags: rerender, searchParams, localStorage, optimization
|
||||
---
|
||||
|
||||
## Defer State Reads to Usage Point
|
||||
|
||||
Don't subscribe to dynamic state (searchParams, localStorage) if you only read it inside callbacks.
|
||||
|
||||
**Incorrect (subscribes to all searchParams changes):**
|
||||
|
||||
```tsx
|
||||
function ShareButton({ chatId }: { chatId: string }) {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const handleShare = () => {
|
||||
const ref = searchParams.get("ref");
|
||||
shareChat(chatId, { ref });
|
||||
};
|
||||
|
||||
return <button onClick={handleShare}>Share</button>;
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (reads on demand, no subscription):**
|
||||
|
||||
```tsx
|
||||
function ShareButton({ chatId }: { chatId: string }) {
|
||||
const handleShare = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const ref = params.get("ref");
|
||||
shareChat(chatId, { ref });
|
||||
};
|
||||
|
||||
return <button onClick={handleShare}>Share</button>;
|
||||
}
|
||||
```
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
---
|
||||
title: Narrow Effect Dependencies
|
||||
impact: LOW
|
||||
impactDescription: minimizes effect re-runs
|
||||
tags: rerender, useEffect, dependencies, optimization
|
||||
---
|
||||
|
||||
## Narrow Effect Dependencies
|
||||
|
||||
Specify primitive dependencies instead of objects to minimize effect re-runs.
|
||||
|
||||
**Incorrect (re-runs on any user field change):**
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
console.log(user.id);
|
||||
}, [user]);
|
||||
```
|
||||
|
||||
**Correct (re-runs only when id changes):**
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
console.log(user.id);
|
||||
}, [user.id]);
|
||||
```
|
||||
|
||||
**For derived state, compute outside effect:**
|
||||
|
||||
```tsx
|
||||
// Incorrect: runs on width=767, 766, 765...
|
||||
useEffect(() => {
|
||||
if (width < 768) {
|
||||
enableMobileMode();
|
||||
}
|
||||
}, [width]);
|
||||
|
||||
// Correct: runs only on boolean transition
|
||||
const isMobile = width < 768;
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
enableMobileMode();
|
||||
}
|
||||
}, [isMobile]);
|
||||
```
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
title: Calculate Derived State During Rendering
|
||||
impact: MEDIUM
|
||||
impactDescription: avoids redundant renders and state drift
|
||||
tags: rerender, derived-state, useEffect, state
|
||||
---
|
||||
|
||||
## Calculate Derived State During Rendering
|
||||
|
||||
If a value can be computed from current props/state, do not store it in state or update it in an effect. Derive it during render to avoid extra renders and state drift. Do not set state in effects solely in response to prop changes; prefer derived values or keyed resets instead.
|
||||
|
||||
**Incorrect (redundant state and effect):**
|
||||
|
||||
```tsx
|
||||
function Form() {
|
||||
const [firstName, setFirstName] = useState("First");
|
||||
const [lastName, setLastName] = useState("Last");
|
||||
const [fullName, setFullName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setFullName(firstName + " " + lastName);
|
||||
}, [firstName, lastName]);
|
||||
|
||||
return <p>{fullName}</p>;
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (derive during render):**
|
||||
|
||||
```tsx
|
||||
function Form() {
|
||||
const [firstName, setFirstName] = useState("First");
|
||||
const [lastName, setLastName] = useState("Last");
|
||||
const fullName = firstName + " " + lastName;
|
||||
|
||||
return <p>{fullName}</p>;
|
||||
}
|
||||
```
|
||||
|
||||
References: [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect)
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
title: Subscribe to Derived State
|
||||
impact: MEDIUM
|
||||
impactDescription: reduces re-render frequency
|
||||
tags: rerender, derived-state, media-query, optimization
|
||||
---
|
||||
|
||||
## Subscribe to Derived State
|
||||
|
||||
Subscribe to derived boolean state instead of continuous values to reduce re-render frequency.
|
||||
|
||||
**Incorrect (re-renders on every pixel change):**
|
||||
|
||||
```tsx
|
||||
function Sidebar() {
|
||||
const width = useWindowWidth(); // updates continuously
|
||||
const isMobile = width < 768;
|
||||
return <nav className={isMobile ? "mobile" : "desktop"} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (re-renders only when boolean changes):**
|
||||
|
||||
```tsx
|
||||
function Sidebar() {
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
return <nav className={isMobile ? "mobile" : "desktop"} />;
|
||||
}
|
||||
```
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
---
|
||||
title: Use Functional setState Updates
|
||||
impact: MEDIUM
|
||||
impactDescription: prevents stale closures and unnecessary callback recreations
|
||||
tags: react, hooks, useState, useCallback, callbacks, closures
|
||||
---
|
||||
|
||||
## Use Functional setState Updates
|
||||
|
||||
When updating state based on the current state value, use the functional update form of setState instead of directly referencing the state variable. This prevents stale closures, eliminates unnecessary dependencies, and creates stable callback references.
|
||||
|
||||
**Incorrect (requires state as dependency):**
|
||||
|
||||
```tsx
|
||||
function TodoList() {
|
||||
const [items, setItems] = useState(initialItems);
|
||||
|
||||
// Callback must depend on items, recreated on every items change
|
||||
const addItems = useCallback(
|
||||
(newItems: Item[]) => {
|
||||
setItems([...items, ...newItems]);
|
||||
},
|
||||
[items],
|
||||
); // ❌ items dependency causes recreations
|
||||
|
||||
// Risk of stale closure if dependency is forgotten
|
||||
const removeItem = useCallback((id: string) => {
|
||||
setItems(items.filter((item) => item.id !== id));
|
||||
}, []); // ❌ Missing items dependency - will use stale items!
|
||||
|
||||
return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />;
|
||||
}
|
||||
```
|
||||
|
||||
The first callback is recreated every time `items` changes, which can cause child components to re-render unnecessarily. The second callback has a stale closure bug—it will always reference the initial `items` value.
|
||||
|
||||
**Correct (stable callbacks, no stale closures):**
|
||||
|
||||
```tsx
|
||||
function TodoList() {
|
||||
const [items, setItems] = useState(initialItems);
|
||||
|
||||
// Stable callback, never recreated
|
||||
const addItems = useCallback((newItems: Item[]) => {
|
||||
setItems((curr) => [...curr, ...newItems]);
|
||||
}, []); // ✅ No dependencies needed
|
||||
|
||||
// Always uses latest state, no stale closure risk
|
||||
const removeItem = useCallback((id: string) => {
|
||||
setItems((curr) => curr.filter((item) => item.id !== id));
|
||||
}, []); // ✅ Safe and stable
|
||||
|
||||
return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
1. **Stable callback references** - Callbacks don't need to be recreated when state changes
|
||||
2. **No stale closures** - Always operates on the latest state value
|
||||
3. **Fewer dependencies** - Simplifies dependency arrays and reduces memory leaks
|
||||
4. **Prevents bugs** - Eliminates the most common source of React closure bugs
|
||||
|
||||
**When to use functional updates:**
|
||||
|
||||
- Any setState that depends on the current state value
|
||||
- Inside useCallback/useMemo when state is needed
|
||||
- Event handlers that reference state
|
||||
- Async operations that update state
|
||||
|
||||
**When direct updates are fine:**
|
||||
|
||||
- Setting state to a static value: `setCount(0)`
|
||||
- Setting state from props/arguments only: `setName(newName)`
|
||||
- State doesn't depend on previous value
|
||||
|
||||
**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler can automatically optimize some cases, but functional updates are still recommended for correctness and to prevent stale closure bugs.
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
title: Use Lazy State Initialization
|
||||
impact: MEDIUM
|
||||
impactDescription: wasted computation on every render
|
||||
tags: react, hooks, useState, performance, initialization
|
||||
---
|
||||
|
||||
## Use Lazy State Initialization
|
||||
|
||||
Pass a function to `useState` for expensive initial values. Without the function form, the initializer runs on every render even though the value is only used once.
|
||||
|
||||
**Incorrect (runs on every render):**
|
||||
|
||||
```tsx
|
||||
function FilteredList({ items }: { items: Item[] }) {
|
||||
// buildSearchIndex() runs on EVERY render, even after initialization
|
||||
const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items));
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
// When query changes, buildSearchIndex runs again unnecessarily
|
||||
return <SearchResults index={searchIndex} query={query} />;
|
||||
}
|
||||
|
||||
function UserProfile() {
|
||||
// JSON.parse runs on every render
|
||||
const [settings, setSettings] = useState(
|
||||
JSON.parse(localStorage.getItem("settings") || "{}"),
|
||||
);
|
||||
|
||||
return <SettingsForm settings={settings} onChange={setSettings} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (runs only once):**
|
||||
|
||||
```tsx
|
||||
function FilteredList({ items }: { items: Item[] }) {
|
||||
// buildSearchIndex() runs ONLY on initial render
|
||||
const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items));
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
return <SearchResults index={searchIndex} query={query} />;
|
||||
}
|
||||
|
||||
function UserProfile() {
|
||||
// JSON.parse runs only on initial render
|
||||
const [settings, setSettings] = useState(() => {
|
||||
const stored = localStorage.getItem("settings");
|
||||
return stored ? JSON.parse(stored) : {};
|
||||
});
|
||||
|
||||
return <SettingsForm settings={settings} onChange={setSettings} />;
|
||||
}
|
||||
```
|
||||
|
||||
Use lazy initialization when computing initial values from localStorage/sessionStorage, building data structures (indexes, maps), reading from the DOM, or performing heavy transformations.
|
||||
|
||||
For simple primitives (`useState(0)`), direct references (`useState(props.value)`), or cheap literals (`useState({})`), the function form is unnecessary.
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
title: Extract Default Non-primitive Parameter Value from Memoized Component to Constant
|
||||
impact: MEDIUM
|
||||
impactDescription: restores memoization by using a constant for default value
|
||||
tags: rerender, memo, optimization
|
||||
---
|
||||
|
||||
## Extract Default Non-primitive Parameter Value from Memoized Component to Constant
|
||||
|
||||
When memoized component has a default value for some non-primitive optional parameter, such as an array, function, or object, calling the component without that parameter results in broken memoization. This is because new value instances are created on every rerender, and they do not pass strict equality comparison in `memo()`.
|
||||
|
||||
To address this issue, extract the default value into a constant.
|
||||
|
||||
**Incorrect (`onClick` has different values on every rerender):**
|
||||
|
||||
```tsx
|
||||
const UserAvatar = memo(function UserAvatar({ onClick = () => {} }: { onClick?: () => void }) {
|
||||
// ...
|
||||
})
|
||||
|
||||
// Used without optional onClick
|
||||
<UserAvatar />
|
||||
```
|
||||
|
||||
**Correct (stable default value):**
|
||||
|
||||
```tsx
|
||||
const NOOP = () => {};
|
||||
|
||||
const UserAvatar = memo(function UserAvatar({ onClick = NOOP }: { onClick?: () => void }) {
|
||||
// ...
|
||||
})
|
||||
|
||||
// Used without optional onClick
|
||||
<UserAvatar />
|
||||
```
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
title: Extract to Memoized Components
|
||||
impact: MEDIUM
|
||||
impactDescription: enables early returns
|
||||
tags: rerender, memo, useMemo, optimization
|
||||
---
|
||||
|
||||
## Extract to Memoized Components
|
||||
|
||||
Extract expensive work into memoized components to enable early returns before computation.
|
||||
|
||||
**Incorrect (computes avatar even when loading):**
|
||||
|
||||
```tsx
|
||||
function Profile({ user, loading }: Props) {
|
||||
const avatar = useMemo(() => {
|
||||
const id = computeAvatarId(user);
|
||||
return <Avatar id={id} />;
|
||||
}, [user]);
|
||||
|
||||
if (loading) return <Skeleton />;
|
||||
return <div>{avatar}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (skips computation when loading):**
|
||||
|
||||
```tsx
|
||||
const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
|
||||
const id = useMemo(() => computeAvatarId(user), [user]);
|
||||
return <Avatar id={id} />;
|
||||
});
|
||||
|
||||
function Profile({ user, loading }: Props) {
|
||||
if (loading) return <Skeleton />;
|
||||
return (
|
||||
<div>
|
||||
<UserAvatar user={user} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, manual memoization with `memo()` and `useMemo()` is not necessary. The compiler automatically optimizes re-renders.
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
---
|
||||
title: Put Interaction Logic in Event Handlers
|
||||
impact: MEDIUM
|
||||
impactDescription: avoids effect re-runs and duplicate side effects
|
||||
tags: rerender, useEffect, events, side-effects, dependencies
|
||||
---
|
||||
|
||||
## Put Interaction Logic in Event Handlers
|
||||
|
||||
If a side effect is triggered by a specific user action (submit, click, drag), run it in that event handler. Do not model the action as state + effect; it makes effects re-run on unrelated changes and can duplicate the action.
|
||||
|
||||
**Incorrect (event modeled as state + effect):**
|
||||
|
||||
```tsx
|
||||
function Form() {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const theme = useContext(ThemeContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (submitted) {
|
||||
post("/api/register");
|
||||
showToast("Registered", theme);
|
||||
}
|
||||
}, [submitted, theme]);
|
||||
|
||||
return <button onClick={() => setSubmitted(true)}>Submit</button>;
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (do it in the handler):**
|
||||
|
||||
```tsx
|
||||
function Form() {
|
||||
const theme = useContext(ThemeContext);
|
||||
|
||||
function handleSubmit() {
|
||||
post("/api/register");
|
||||
showToast("Registered", theme);
|
||||
}
|
||||
|
||||
return <button onClick={handleSubmit}>Submit</button>;
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [Should this code move to an event handler?](https://react.dev/learn/removing-effect-dependencies#should-this-code-move-to-an-event-handler)
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
title: Do not wrap a simple expression with a primitive result type in useMemo
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: wasted computation on every render
|
||||
tags: rerender, useMemo, optimization
|
||||
---
|
||||
|
||||
## Do not wrap a simple expression with a primitive result type in useMemo
|
||||
|
||||
When an expression is simple (few logical or arithmetical operators) and has a primitive result type (boolean, number, string), do not wrap it in `useMemo`.
|
||||
Calling `useMemo` and comparing hook dependencies may consume more resources than the expression itself.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
function Header({ user, notifications }: Props) {
|
||||
const isLoading = useMemo(() => {
|
||||
return user.isLoading || notifications.isLoading;
|
||||
}, [user.isLoading, notifications.isLoading]);
|
||||
|
||||
if (isLoading) return <Skeleton />;
|
||||
// return some markup
|
||||
}
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
function Header({ user, notifications }: Props) {
|
||||
const isLoading = user.isLoading || notifications.isLoading;
|
||||
|
||||
if (isLoading) return <Skeleton />;
|
||||
// return some markup
|
||||
}
|
||||
```
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
title: Use Transitions for Non-Urgent Updates
|
||||
impact: MEDIUM
|
||||
impactDescription: maintains UI responsiveness
|
||||
tags: rerender, transitions, startTransition, performance
|
||||
---
|
||||
|
||||
## Use Transitions for Non-Urgent Updates
|
||||
|
||||
Mark frequent, non-urgent state updates as transitions to maintain UI responsiveness.
|
||||
|
||||
**Incorrect (blocks UI on every scroll):**
|
||||
|
||||
```tsx
|
||||
function ScrollTracker() {
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
useEffect(() => {
|
||||
const handler = () => setScrollY(window.scrollY);
|
||||
window.addEventListener("scroll", handler, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handler);
|
||||
}, []);
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (non-blocking updates):**
|
||||
|
||||
```tsx
|
||||
import { startTransition } from "react";
|
||||
|
||||
function ScrollTracker() {
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
startTransition(() => setScrollY(window.scrollY));
|
||||
};
|
||||
window.addEventListener("scroll", handler, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handler);
|
||||
}, []);
|
||||
}
|
||||
```
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
---
|
||||
title: Use useRef for Transient Values
|
||||
impact: MEDIUM
|
||||
impactDescription: avoids unnecessary re-renders on frequent updates
|
||||
tags: rerender, useref, state, performance
|
||||
---
|
||||
|
||||
## Use useRef for Transient Values
|
||||
|
||||
When a value changes frequently and you don't want a re-render on every update (e.g., mouse trackers, intervals, transient flags), store it in `useRef` instead of `useState`. Keep component state for UI; use refs for temporary DOM-adjacent values. Updating a ref does not trigger a re-render.
|
||||
|
||||
**Incorrect (renders every update):**
|
||||
|
||||
```tsx
|
||||
function Tracker() {
|
||||
const [lastX, setLastX] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent) => setLastX(e.clientX);
|
||||
window.addEventListener("mousemove", onMove);
|
||||
return () => window.removeEventListener("mousemove", onMove);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: lastX,
|
||||
width: 8,
|
||||
height: 8,
|
||||
background: "black",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (no re-render for tracking):**
|
||||
|
||||
```tsx
|
||||
function Tracker() {
|
||||
const lastXRef = useRef(0);
|
||||
const dotRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent) => {
|
||||
lastXRef.current = e.clientX;
|
||||
const node = dotRef.current;
|
||||
if (node) {
|
||||
node.style.transform = `translateX(${e.clientX}px)`;
|
||||
}
|
||||
};
|
||||
window.addEventListener("mousemove", onMove);
|
||||
return () => window.removeEventListener("mousemove", onMove);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dotRef}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 8,
|
||||
height: 8,
|
||||
background: "black",
|
||||
transform: "translateX(0px)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
---
|
||||
title: Use after() for Non-Blocking Operations
|
||||
impact: MEDIUM
|
||||
impactDescription: faster response times
|
||||
tags: server, async, logging, analytics, side-effects
|
||||
---
|
||||
|
||||
## Use after() for Non-Blocking Operations
|
||||
|
||||
Use Next.js's `after()` to schedule work that should execute after a response is sent. This prevents logging, analytics, and other side effects from blocking the response.
|
||||
|
||||
**Incorrect (blocks response):**
|
||||
|
||||
```tsx
|
||||
import { logUserAction } from "@/app/utils";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Perform mutation
|
||||
await updateDatabase(request);
|
||||
|
||||
// Logging blocks the response
|
||||
const userAgent = request.headers.get("user-agent") || "unknown";
|
||||
await logUserAction({ userAgent });
|
||||
|
||||
return new Response(JSON.stringify({ status: "success" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (non-blocking):**
|
||||
|
||||
```tsx
|
||||
import { after } from "next/server";
|
||||
import { headers, cookies } from "next/headers";
|
||||
import { logUserAction } from "@/app/utils";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Perform mutation
|
||||
await updateDatabase(request);
|
||||
|
||||
// Log after response is sent
|
||||
after(async () => {
|
||||
const userAgent = (await headers()).get("user-agent") || "unknown";
|
||||
const sessionCookie =
|
||||
(await cookies()).get("session-id")?.value || "anonymous";
|
||||
|
||||
logUserAction({ sessionCookie, userAgent });
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ status: "success" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
The response is sent immediately while logging happens in the background.
|
||||
|
||||
**Common use cases:**
|
||||
|
||||
- Analytics tracking
|
||||
- Audit logging
|
||||
- Sending notifications
|
||||
- Cache invalidation
|
||||
- Cleanup tasks
|
||||
|
||||
**Important notes:**
|
||||
|
||||
- `after()` runs even if the response fails or redirects
|
||||
- Works in Server Actions, Route Handlers, and Server Components
|
||||
|
||||
Reference: [https://nextjs.org/docs/app/api-reference/functions/after](https://nextjs.org/docs/app/api-reference/functions/after)
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
---
|
||||
title: Authenticate Server Actions Like API Routes
|
||||
impact: CRITICAL
|
||||
impactDescription: prevents unauthorized access to server mutations
|
||||
tags: server, server-actions, authentication, security, authorization
|
||||
---
|
||||
|
||||
## Authenticate Server Actions Like API Routes
|
||||
|
||||
**Impact: CRITICAL (prevents unauthorized access to server mutations)**
|
||||
|
||||
Server Actions (functions with `"use server"`) are exposed as public endpoints, just like API routes. Always verify authentication and authorization **inside** each Server Action—do not rely solely on middleware, layout guards, or page-level checks, as Server Actions can be invoked directly.
|
||||
|
||||
Next.js documentation explicitly states: "Treat Server Actions with the same security considerations as public-facing API endpoints, and verify if the user is allowed to perform a mutation."
|
||||
|
||||
**Incorrect (no authentication check):**
|
||||
|
||||
```typescript
|
||||
"use server";
|
||||
|
||||
export async function deleteUser(userId: string) {
|
||||
// Anyone can call this! No auth check
|
||||
await db.user.delete({ where: { id: userId } });
|
||||
return { success: true };
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (authentication inside the action):**
|
||||
|
||||
```typescript
|
||||
"use server";
|
||||
|
||||
import { verifySession } from "@/lib/auth";
|
||||
import { unauthorized } from "@/lib/errors";
|
||||
|
||||
export async function deleteUser(userId: string) {
|
||||
// Always check auth inside the action
|
||||
const session = await verifySession();
|
||||
|
||||
if (!session) {
|
||||
throw unauthorized("Must be logged in");
|
||||
}
|
||||
|
||||
// Check authorization too
|
||||
if (session.user.role !== "admin" && session.user.id !== userId) {
|
||||
throw unauthorized("Cannot delete other users");
|
||||
}
|
||||
|
||||
await db.user.delete({ where: { id: userId } });
|
||||
return { success: true };
|
||||
}
|
||||
```
|
||||
|
||||
**With input validation:**
|
||||
|
||||
```typescript
|
||||
"use server";
|
||||
|
||||
import { verifySession } from "@/lib/auth";
|
||||
import { z } from "zod";
|
||||
|
||||
const updateProfileSchema = z.object({
|
||||
userId: z.string().uuid(),
|
||||
name: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export async function updateProfile(data: unknown) {
|
||||
// Validate input first
|
||||
const validated = updateProfileSchema.parse(data);
|
||||
|
||||
// Then authenticate
|
||||
const session = await verifySession();
|
||||
if (!session) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
// Then authorize
|
||||
if (session.user.id !== validated.userId) {
|
||||
throw new Error("Can only update own profile");
|
||||
}
|
||||
|
||||
// Finally perform the mutation
|
||||
await db.user.update({
|
||||
where: { id: validated.userId },
|
||||
data: {
|
||||
name: validated.name,
|
||||
email: validated.email,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [https://nextjs.org/docs/app/guides/authentication](https://nextjs.org/docs/app/guides/authentication)
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
title: Cross-Request LRU Caching
|
||||
impact: HIGH
|
||||
impactDescription: caches across requests
|
||||
tags: server, cache, lru, cross-request
|
||||
---
|
||||
|
||||
## Cross-Request LRU Caching
|
||||
|
||||
`React.cache()` only works within one request. For data shared across sequential requests (user clicks button A then button B), use an LRU cache.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```typescript
|
||||
import { LRUCache } from "lru-cache";
|
||||
|
||||
const cache = new LRUCache<string, any>({
|
||||
max: 1000,
|
||||
ttl: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
export async function getUser(id: string) {
|
||||
const cached = cache.get(id);
|
||||
if (cached) return cached;
|
||||
|
||||
const user = await db.user.findUnique({ where: { id } });
|
||||
cache.set(id, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
// Request 1: DB query, result cached
|
||||
// Request 2: cache hit, no DB query
|
||||
```
|
||||
|
||||
Use when sequential user actions hit multiple endpoints needing the same data within seconds.
|
||||
|
||||
**With Vercel's [Fluid Compute](https://vercel.com/docs/fluid-compute):** LRU caching is especially effective because multiple concurrent requests can share the same function instance and cache. This means the cache persists across requests without needing external storage like Redis.
|
||||
|
||||
**In traditional serverless:** Each invocation runs in isolation, so consider Redis for cross-process caching.
|
||||
|
||||
Reference: [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache)
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
---
|
||||
title: Per-Request Deduplication with React.cache()
|
||||
impact: MEDIUM
|
||||
impactDescription: deduplicates within request
|
||||
tags: server, cache, react-cache, deduplication
|
||||
---
|
||||
|
||||
## Per-Request Deduplication with React.cache()
|
||||
|
||||
Use `React.cache()` for server-side request deduplication. Authentication and database queries benefit most.
|
||||
|
||||
**Usage:**
|
||||
|
||||
```typescript
|
||||
import { cache } from "react";
|
||||
|
||||
export const getCurrentUser = cache(async () => {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return null;
|
||||
return await db.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Within a single request, multiple calls to `getCurrentUser()` execute the query only once.
|
||||
|
||||
**Avoid inline objects as arguments:**
|
||||
|
||||
`React.cache()` uses shallow equality (`Object.is`) to determine cache hits. Inline objects create new references each call, preventing cache hits.
|
||||
|
||||
**Incorrect (always cache miss):**
|
||||
|
||||
```typescript
|
||||
const getUser = cache(async (params: { uid: number }) => {
|
||||
return await db.user.findUnique({ where: { id: params.uid } });
|
||||
});
|
||||
|
||||
// Each call creates new object, never hits cache
|
||||
getUser({ uid: 1 });
|
||||
getUser({ uid: 1 }); // Cache miss, runs query again
|
||||
```
|
||||
|
||||
**Correct (cache hit):**
|
||||
|
||||
```typescript
|
||||
const getUser = cache(async (uid: number) => {
|
||||
return await db.user.findUnique({ where: { id: uid } });
|
||||
});
|
||||
|
||||
// Primitive args use value equality
|
||||
getUser(1);
|
||||
getUser(1); // Cache hit, returns cached result
|
||||
```
|
||||
|
||||
If you must pass objects, pass the same reference:
|
||||
|
||||
```typescript
|
||||
const params = { uid: 1 };
|
||||
getUser(params); // Query runs
|
||||
getUser(params); // Cache hit (same reference)
|
||||
```
|
||||
|
||||
**Next.js-Specific Note:**
|
||||
|
||||
In Next.js, the `fetch` API is automatically extended with request memoization. Requests with the same URL and options are automatically deduplicated within a single request, so you don't need `React.cache()` for `fetch` calls. However, `React.cache()` is still essential for other async tasks:
|
||||
|
||||
- Database queries (Prisma, Drizzle, etc.)
|
||||
- Heavy computations
|
||||
- Authentication checks
|
||||
- File system operations
|
||||
- Any non-fetch async work
|
||||
|
||||
Use `React.cache()` to deduplicate these operations across your component tree.
|
||||
|
||||
Reference: [React.cache documentation](https://react.dev/reference/react/cache)
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
---
|
||||
title: Avoid Duplicate Serialization in RSC Props
|
||||
impact: LOW
|
||||
impactDescription: reduces network payload by avoiding duplicate serialization
|
||||
tags: server, rsc, serialization, props, client-components
|
||||
---
|
||||
|
||||
## Avoid Duplicate Serialization in RSC Props
|
||||
|
||||
**Impact: LOW (reduces network payload by avoiding duplicate serialization)**
|
||||
|
||||
RSC→client serialization deduplicates by object reference, not value. Same reference = serialized once; new reference = serialized again. Do transformations (`.toSorted()`, `.filter()`, `.map()`) in client, not server.
|
||||
|
||||
**Incorrect (duplicates array):**
|
||||
|
||||
```tsx
|
||||
// RSC: sends 6 strings (2 arrays × 3 items)
|
||||
<ClientList usernames={usernames} usernamesOrdered={usernames.toSorted()} />
|
||||
```
|
||||
|
||||
**Correct (sends 3 strings):**
|
||||
|
||||
```tsx
|
||||
// RSC: send once
|
||||
<ClientList usernames={usernames} />;
|
||||
|
||||
// Client: transform there
|
||||
("use client");
|
||||
const sorted = useMemo(() => [...usernames].sort(), [usernames]);
|
||||
```
|
||||
|
||||
**Nested deduplication behavior:**
|
||||
|
||||
Deduplication works recursively. Impact varies by data type:
|
||||
|
||||
- `string[]`, `number[]`, `boolean[]`: **HIGH impact** - array + all primitives fully duplicated
|
||||
- `object[]`: **LOW impact** - array duplicated, but nested objects deduplicated by reference
|
||||
|
||||
```tsx
|
||||
// string[] - duplicates everything
|
||||
usernames={['a','b']} sorted={usernames.toSorted()} // sends 4 strings
|
||||
|
||||
// object[] - duplicates array structure only
|
||||
users={[{id:1},{id:2}]} sorted={users.toSorted()} // sends 2 arrays + 2 unique objects (not 4)
|
||||
```
|
||||
|
||||
**Operations breaking deduplication (create new references):**
|
||||
|
||||
- Arrays: `.toSorted()`, `.filter()`, `.map()`, `.slice()`, `[...arr]`
|
||||
- Objects: `{...obj}`, `Object.assign()`, `structuredClone()`, `JSON.parse(JSON.stringify())`
|
||||
|
||||
**More examples:**
|
||||
|
||||
```tsx
|
||||
// ❌ Bad
|
||||
<C users={users} active={users.filter(u => u.active)} />
|
||||
<C product={product} productName={product.name} />
|
||||
|
||||
// ✅ Good
|
||||
<C users={users} />
|
||||
<C product={product} />
|
||||
// Do filtering/destructuring in client
|
||||
```
|
||||
|
||||
**Exception:** Pass derived data when transformation is expensive or client doesn't need original.
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
---
|
||||
title: Hoist Static I/O to Module Level
|
||||
impact: HIGH
|
||||
impactDescription: avoids repeated file/network I/O per request
|
||||
tags: server, io, performance, next.js, route-handlers, og-image
|
||||
---
|
||||
|
||||
## Hoist Static I/O to Module Level
|
||||
|
||||
**Impact: HIGH (avoids repeated file/network I/O per request)**
|
||||
|
||||
When loading static assets (fonts, logos, images, config files) in route handlers or server functions, hoist the I/O operation to module level. Module-level code runs once when the module is first imported, not on every request. This eliminates redundant file system reads or network fetches that would otherwise run on every invocation.
|
||||
|
||||
**Incorrect: reads font file on every request**
|
||||
|
||||
```typescript
|
||||
// app/api/og/route.tsx
|
||||
import { ImageResponse } from 'next/og'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
// Runs on EVERY request - expensive!
|
||||
const fontData = await fetch(
|
||||
new URL('./fonts/Inter.ttf', import.meta.url)
|
||||
).then(res => res.arrayBuffer())
|
||||
|
||||
const logoData = await fetch(
|
||||
new URL('./images/logo.png', import.meta.url)
|
||||
).then(res => res.arrayBuffer())
|
||||
|
||||
return new ImageResponse(
|
||||
<div style={{ fontFamily: 'Inter' }}>
|
||||
<img src={logoData} />
|
||||
Hello World
|
||||
</div>,
|
||||
{ fonts: [{ name: 'Inter', data: fontData }] }
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct: loads once at module initialization**
|
||||
|
||||
```typescript
|
||||
// app/api/og/route.tsx
|
||||
import { ImageResponse } from 'next/og'
|
||||
|
||||
// Module-level: runs ONCE when module is first imported
|
||||
const fontData = fetch(
|
||||
new URL('./fonts/Inter.ttf', import.meta.url)
|
||||
).then(res => res.arrayBuffer())
|
||||
|
||||
const logoData = fetch(
|
||||
new URL('./images/logo.png', import.meta.url)
|
||||
).then(res => res.arrayBuffer())
|
||||
|
||||
export async function GET(request: Request) {
|
||||
// Await the already-started promises
|
||||
const [font, logo] = await Promise.all([fontData, logoData])
|
||||
|
||||
return new ImageResponse(
|
||||
<div style={{ fontFamily: 'Inter' }}>
|
||||
<img src={logo} />
|
||||
Hello World
|
||||
</div>,
|
||||
{ fonts: [{ name: 'Inter', data: font }] }
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Alternative: synchronous file reads with Node.js fs**
|
||||
|
||||
```typescript
|
||||
// app/api/og/route.tsx
|
||||
import { ImageResponse } from 'next/og'
|
||||
import { readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
// Synchronous read at module level - blocks only during module init
|
||||
const fontData = readFileSync(
|
||||
join(process.cwd(), 'public/fonts/Inter.ttf')
|
||||
)
|
||||
|
||||
const logoData = readFileSync(
|
||||
join(process.cwd(), 'public/images/logo.png')
|
||||
)
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return new ImageResponse(
|
||||
<div style={{ fontFamily: 'Inter' }}>
|
||||
<img src={logoData} />
|
||||
Hello World
|
||||
</div>,
|
||||
{ fonts: [{ name: 'Inter', data: fontData }] }
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**General Node.js example: loading config or templates**
|
||||
|
||||
```typescript
|
||||
// Incorrect: reads config on every call
|
||||
export async function processRequest(data: Data) {
|
||||
const config = JSON.parse(await fs.readFile("./config.json", "utf-8"));
|
||||
const template = await fs.readFile("./template.html", "utf-8");
|
||||
|
||||
return render(template, data, config);
|
||||
}
|
||||
|
||||
// Correct: loads once at module level
|
||||
const configPromise = fs.readFile("./config.json", "utf-8").then(JSON.parse);
|
||||
const templatePromise = fs.readFile("./template.html", "utf-8");
|
||||
|
||||
export async function processRequest(data: Data) {
|
||||
const [config, template] = await Promise.all([
|
||||
configPromise,
|
||||
templatePromise,
|
||||
]);
|
||||
|
||||
return render(template, data, config);
|
||||
}
|
||||
```
|
||||
|
||||
**When to use this pattern:**
|
||||
|
||||
- Loading fonts for OG image generation
|
||||
- Loading static logos, icons, or watermarks
|
||||
- Reading configuration files that don't change at runtime
|
||||
- Loading email templates or other static templates
|
||||
- Any static asset that's the same across all requests
|
||||
|
||||
**When NOT to use this pattern:**
|
||||
|
||||
- Assets that vary per request or user
|
||||
- Files that may change during runtime (use caching with TTL instead)
|
||||
- Large files that would consume too much memory if kept loaded
|
||||
- Sensitive data that shouldn't persist in memory
|
||||
|
||||
**With Vercel's [Fluid Compute](https://vercel.com/docs/fluid-compute):** Module-level caching is especially effective because multiple concurrent requests share the same function instance. The static assets stay loaded in memory across requests without cold start penalties.
|
||||
|
||||
**In traditional serverless:** Each cold start re-executes module-level code, but subsequent warm invocations reuse the loaded assets until the instance is recycled.
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
---
|
||||
title: Parallel Data Fetching with Component Composition
|
||||
impact: CRITICAL
|
||||
impactDescription: eliminates server-side waterfalls
|
||||
tags: server, rsc, parallel-fetching, composition
|
||||
---
|
||||
|
||||
## Parallel Data Fetching with Component Composition
|
||||
|
||||
React Server Components execute sequentially within a tree. Restructure with composition to parallelize data fetching.
|
||||
|
||||
**Incorrect (Sidebar waits for Page's fetch to complete):**
|
||||
|
||||
```tsx
|
||||
export default async function Page() {
|
||||
const header = await fetchHeader();
|
||||
return (
|
||||
<div>
|
||||
<div>{header}</div>
|
||||
<Sidebar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function Sidebar() {
|
||||
const items = await fetchSidebarItems();
|
||||
return <nav>{items.map(renderItem)}</nav>;
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (both fetch simultaneously):**
|
||||
|
||||
```tsx
|
||||
async function Header() {
|
||||
const data = await fetchHeader();
|
||||
return <div>{data}</div>;
|
||||
}
|
||||
|
||||
async function Sidebar() {
|
||||
const items = await fetchSidebarItems();
|
||||
return <nav>{items.map(renderItem)}</nav>;
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<Sidebar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Alternative with children prop:**
|
||||
|
||||
```tsx
|
||||
async function Header() {
|
||||
const data = await fetchHeader();
|
||||
return <div>{data}</div>;
|
||||
}
|
||||
|
||||
async function Sidebar() {
|
||||
const items = await fetchSidebarItems();
|
||||
return <nav>{items.map(renderItem)}</nav>;
|
||||
}
|
||||
|
||||
function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Layout>
|
||||
<Sidebar />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
title: Minimize Serialization at RSC Boundaries
|
||||
impact: HIGH
|
||||
impactDescription: reduces data transfer size
|
||||
tags: server, rsc, serialization, props
|
||||
---
|
||||
|
||||
## Minimize Serialization at RSC Boundaries
|
||||
|
||||
The React Server/Client boundary serializes all object properties into strings and embeds them in the HTML response and subsequent RSC requests. This serialized data directly impacts page weight and load time, so **size matters a lot**. Only pass fields that the client actually uses.
|
||||
|
||||
**Incorrect (serializes all 50 fields):**
|
||||
|
||||
```tsx
|
||||
async function Page() {
|
||||
const user = await fetchUser(); // 50 fields
|
||||
return <Profile user={user} />;
|
||||
}
|
||||
|
||||
("use client");
|
||||
function Profile({ user }: { user: User }) {
|
||||
return <div>{user.name}</div>; // uses 1 field
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (serializes only 1 field):**
|
||||
|
||||
```tsx
|
||||
async function Page() {
|
||||
const user = await fetchUser();
|
||||
return <Profile name={user.name} />;
|
||||
}
|
||||
|
||||
("use client");
|
||||
function Profile({ name }: { name: string }) {
|
||||
return <div>{name}</div>;
|
||||
}
|
||||
```
|
||||
40
.agents/skills/web-design-guidelines/SKILL.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
name: web-design-guidelines
|
||||
description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
|
||||
metadata:
|
||||
author: vercel
|
||||
version: "1.0.0"
|
||||
argument-hint: <file-or-pattern>
|
||||
---
|
||||
|
||||
# Web Interface Guidelines
|
||||
|
||||
Review files for compliance with Web Interface Guidelines.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Fetch the latest guidelines from the source URL below
|
||||
2. Read the specified files (or prompt user for files/pattern)
|
||||
3. Check against all rules in the fetched guidelines
|
||||
4. Output findings in the terse `file:line` format
|
||||
|
||||
## Guidelines Source
|
||||
|
||||
Fetch fresh guidelines before each review:
|
||||
|
||||
```
|
||||
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
|
||||
```
|
||||
|
||||
Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions.
|
||||
|
||||
## Usage
|
||||
|
||||
When a user provides a file or pattern argument:
|
||||
|
||||
1. Fetch guidelines from the source URL above
|
||||
2. Read the specified files
|
||||
3. Apply all rules from the fetched guidelines
|
||||
4. Output findings using the format specified in the guidelines
|
||||
|
||||
If no files specified, ask the user which files to review.
|
||||
31
.cursor/rules/agent-rules.mdc
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
description: Agent rules to cut token usage.
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
## Before Writing Code
|
||||
|
||||
- Read all relevant files first. Never edit blind.
|
||||
- Understand the full requirement before writing anything.
|
||||
|
||||
## While Writing Code
|
||||
|
||||
- Test after writing. Never leave code untested.
|
||||
- Fix errors before moving on. Never skip failures.
|
||||
- Prefer editing over rewriting whole files.
|
||||
- Simplest working solution. No over-engineering.
|
||||
|
||||
## Before Declaring Done
|
||||
|
||||
- Run the code one final time to confirm it works.
|
||||
- Never declare done without a passing test.
|
||||
|
||||
## Output
|
||||
|
||||
- No sycophantic openers or closing fluff.
|
||||
- No em dashes, smart quotes, or Unicode. ASCII only.
|
||||
- Be concise. If unsure, say so. Never guess.
|
||||
|
||||
## Override Rule
|
||||
|
||||
User instructions always override this file.
|
||||
76
.cursor/rules/caveman.mdc
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
---
|
||||
name: caveman
|
||||
description: >
|
||||
Ultra-compressed communication mode. Slash token usage ~75% by speaking like caveman
|
||||
while keeping full technical accuracy. Use when user says "caveman mode", "talk like caveman",
|
||||
"use caveman", "less tokens", "be brief", or invokes /caveman. Also auto-triggers
|
||||
when token efficiency is requested.
|
||||
---
|
||||
|
||||
# Caveman Mode
|
||||
|
||||
## Core Rule
|
||||
|
||||
Respond like smart caveman. Cut articles, filler, pleasantries. Keep all technical substance.
|
||||
|
||||
## Grammar
|
||||
|
||||
- Drop articles (a, an, the)
|
||||
- Drop filler (just, really, basically, actually, simply)
|
||||
- Drop pleasantries (sure, certainly, of course, happy to)
|
||||
- Short synonyms (big not extensive, fix not "implement a solution for")
|
||||
- No hedging (skip "it might be worth considering")
|
||||
- Fragments fine. No need full sentence
|
||||
- Technical terms stay exact. "Polymorphism" stays "polymorphism"
|
||||
- Code blocks unchanged. Caveman speak around code, not in code
|
||||
- Error messages quoted exact. Caveman only for explanation
|
||||
|
||||
## Pattern
|
||||
|
||||
```
|
||||
[thing] [action] [reason]. [next step].
|
||||
```
|
||||
|
||||
Not:
|
||||
> Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by...
|
||||
|
||||
Yes:
|
||||
> Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:
|
||||
|
||||
## Examples
|
||||
|
||||
**User:** Why is my React component re-rendering?
|
||||
|
||||
**Normal (69 tokens):** "The reason your React component is re-rendering is likely because you're creating a new object reference on each render cycle. When you pass an inline object as a prop, React's shallow comparison sees it as a different object every time, which triggers a re-render. I'd recommend using useMemo to memoize the object."
|
||||
|
||||
**Caveman (19 tokens):** "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
|
||||
|
||||
---
|
||||
|
||||
**User:** How do I set up a PostgreSQL connection pool?
|
||||
|
||||
**Caveman:**
|
||||
```
|
||||
Use `pg` pool:
|
||||
```
|
||||
```js
|
||||
const pool = new Pool({
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
})
|
||||
```
|
||||
```
|
||||
max = concurrent connections. Keep under DB limit. idleTimeout kill stale conn.
|
||||
```
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Code: write normal. Caveman English only
|
||||
- Git commits: normal
|
||||
- PR descriptions: normal
|
||||
- User say "stop caveman" or "normal mode": revert immediately
|
||||
|
|
@ -10,7 +10,6 @@ NEXT_PUBLIC_SENTRY_DSN=""
|
|||
|
||||
# Resend (For emails) Create account and paste API keys in .env.local
|
||||
RESEND_API_KEY=""
|
||||
RESEND_AUDIENCE_ID=""
|
||||
|
||||
# Upstash Redis (For subscription tokens) Create account and paste API keys in .env.local
|
||||
UPSTASH_REDIS_REST_URL=""
|
||||
|
|
|
|||
BIN
.github/demos/easy-invoice-github-demo.gif
vendored
Normal file
|
After Width: | Height: | Size: 6.4 MiB |
BIN
.github/demos/instant-download.gif
vendored
|
Before Width: | Height: | Size: 2 MiB After Width: | Height: | Size: 1.8 MiB |
BIN
.github/demos/invoice-template.gif
vendored
|
Before Width: | Height: | Size: 1.7 MiB |
BIN
.github/demos/lang-currency.gif
vendored
|
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 2 MiB |
BIN
.github/demos/live-pdf-preview.gif
vendored
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
.github/demos/live-preview.gif
vendored
|
Before Width: | Height: | Size: 5.3 MiB |
BIN
.github/demos/qr-code-and-multi-page-pdf.gif
vendored
Normal file
|
After Width: | Height: | Size: 6.5 MiB |
BIN
.github/demos/qr-code.gif
vendored
|
Before Width: | Height: | Size: 7.1 MiB |
BIN
.github/demos/share-link.gif
vendored
|
Before Width: | Height: | Size: 2.3 MiB |
BIN
.github/demos/shared-links.gif
vendored
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
.github/demos/tax-custom.gif
vendored
|
Before Width: | Height: | Size: 2.6 MiB After Width: | Height: | Size: 2.5 MiB |
BIN
.github/screenshots/default-template.png
vendored
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 253 KiB |
BIN
.github/screenshots/easy-invoice-logo-readme.png
vendored
|
Before Width: | Height: | Size: 416 KiB After Width: | Height: | Size: 76 KiB |
41
.github/screenshots/easy-invoice-logo.svg
vendored
|
Before Width: | Height: | Size: 174 KiB |
BIN
.github/screenshots/stripe-template.png
vendored
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 260 KiB |
25
.github/workflows/e2e.yml
vendored
|
|
@ -8,6 +8,10 @@ concurrency:
|
|||
group: e2e-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
# https://vercel.com/guides/how-can-i-run-end-to-end-tests-after-my-vercel-preview-deployment
|
||||
|
|
@ -23,13 +27,13 @@ jobs:
|
|||
- run: |
|
||||
echo "Environment URL - ${{ github.event_name == 'deployment_status' && github.event.deployment_status.environment_url }}"
|
||||
# we use pinned versions because there are safer to use: https://x.com/paulmillr/status/1900948425325031448
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
name: 🛎️ Checkout repository
|
||||
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
name: 📦 Setup pnpm
|
||||
|
||||
- uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # 4.3.0
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
name: 📚 Setup Node.js
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
|
@ -48,9 +52,10 @@ jobs:
|
|||
SENTRY_ENABLED: false
|
||||
NEXT_PUBLIC_SENTRY_ENABLED: false
|
||||
continue-on-error: true
|
||||
run: pnpm exec playwright test --reporter=html,list
|
||||
# https://playwright.dev/docs/test-reporters#line-reporter
|
||||
run: pnpm exec playwright test --reporter=html,line
|
||||
|
||||
- uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 #4.6.1
|
||||
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
name: 🎲 Upload Playwright report
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
|
|
@ -73,11 +78,12 @@ jobs:
|
|||
|
||||
- name: 📧 Send email
|
||||
if: always()
|
||||
uses: dawidd6/action-send-mail@611879133a9569642c41be66f4a323286e9b8a3b # v4
|
||||
# https://github.com/dawidd6/action-send-mail
|
||||
uses: dawidd6/action-send-mail@d38f3f7cd391cdebfe0d38efc3998b935e951c4f # v16
|
||||
with:
|
||||
server_address: smtp.gmail.com
|
||||
server_port: 587
|
||||
from: GitHub Actions
|
||||
from: "GitHub Actions (no-reply) <${{ secrets.EMAIL_USERNAME }}>"
|
||||
to: ${{ secrets.EMAIL_USERNAME }}
|
||||
username: ${{ secrets.EMAIL_USERNAME }}
|
||||
password: ${{ secrets.EMAIL_PASSWORD }}
|
||||
|
|
@ -90,4 +96,9 @@ jobs:
|
|||
For more details, please check:
|
||||
- GitHub Actions run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
${{ steps.pr-url.outputs.pr_url && format('- Pull Request: {0}', steps.pr-url.outputs.pr_url) }}
|
||||
- Vercel deployment: ${{ github.event.deployment_status.environment_url }}
|
||||
attachments: playwright-output/report/index.html
|
||||
|
||||
- name: ❌ Mark workflow failed on E2E Tests failure
|
||||
if: steps.playwright.outcome == 'failure'
|
||||
run: exit 1
|
||||
|
|
|
|||
73
.github/workflows/knip.yml
vendored
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
name: 🔍 Knip
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
knip:
|
||||
name: Run Knip
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# we use pinned versions because there are safer to use: https://x.com/paulmillr/status/1900948425325031448
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
name: 🛎️ Checkout repository
|
||||
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
name: 📦 Setup pnpm
|
||||
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
name: 📚 Setup Node.js
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 🚚 Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: 🔎 Run Knip
|
||||
id: knip
|
||||
continue-on-error: true
|
||||
run: pnpm knip
|
||||
|
||||
- name: 🔍 Get PR URL
|
||||
if: steps.knip.outcome == 'failure'
|
||||
id: pr-url
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PR_URL=$(curl -s \
|
||||
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/pulls?head=${{ github.repository_owner }}:${{ github.ref_name }}" \
|
||||
| jq -r '.[0].html_url // empty')
|
||||
echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 📧 Send email
|
||||
if: steps.knip.outcome == 'failure'
|
||||
# https://github.com/dawidd6/action-send-mail
|
||||
uses: dawidd6/action-send-mail@d38f3f7cd391cdebfe0d38efc3998b935e951c4f # v16
|
||||
with:
|
||||
server_address: smtp.gmail.com
|
||||
server_port: 587
|
||||
from: "GitHub Actions (no-reply) <${{ secrets.EMAIL_USERNAME }}>"
|
||||
to: ${{ secrets.EMAIL_USERNAME }}
|
||||
username: ${{ secrets.EMAIL_USERNAME }}
|
||||
password: ${{ secrets.EMAIL_PASSWORD }}
|
||||
subject: ❌ Knip Failed for ${{ github.repository }}
|
||||
body: |
|
||||
Knip for ${{ github.repository }} has failed.
|
||||
|
||||
For more details, please check:
|
||||
- GitHub Actions run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
${{ steps.pr-url.outputs.pr_url && format('- Pull Request: {0}', steps.pr-url.outputs.pr_url) }}
|
||||
|
||||
- name: ❌ Mark workflow failed on Knip failure
|
||||
if: steps.knip.outcome == 'failure'
|
||||
run: exit 1
|
||||
30
.github/workflows/lint.yml
vendored
|
|
@ -5,6 +5,10 @@ on:
|
|||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Run linting
|
||||
|
|
@ -12,13 +16,13 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# we use pinned versions because there are safer to use: https://x.com/paulmillr/status/1900948425325031448
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
name: 🛎️ Checkout repository
|
||||
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
name: 📦 Setup pnpm
|
||||
|
||||
- uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # 4.3.0
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
name: 📚 Setup Node.js
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
|
@ -28,10 +32,12 @@ jobs:
|
|||
run: pnpm install
|
||||
|
||||
- name: 🔎 Run linting
|
||||
id: lint
|
||||
continue-on-error: true
|
||||
run: pnpm eslint .
|
||||
|
||||
- name: 🔍 Get PR URL
|
||||
if: failure()
|
||||
if: steps.lint.outcome == 'failure'
|
||||
id: pr-url
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -40,16 +46,17 @@ jobs:
|
|||
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/pulls?head=${{ github.repository_owner }}:${{ github.ref_name }}" \
|
||||
| jq -r '.[0].html_url')
|
||||
| jq -r '.[0].html_url // empty')
|
||||
echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 📧 Send email on failure
|
||||
if: failure()
|
||||
uses: dawidd6/action-send-mail@611879133a9569642c41be66f4a323286e9b8a3b # v4
|
||||
- name: 📧 Send email
|
||||
if: steps.lint.outcome == 'failure'
|
||||
# https://github.com/dawidd6/action-send-mail
|
||||
uses: dawidd6/action-send-mail@d38f3f7cd391cdebfe0d38efc3998b935e951c4f # v16
|
||||
with:
|
||||
server_address: smtp.gmail.com
|
||||
server_port: 587
|
||||
from: GitHub Actions
|
||||
from: "GitHub Actions (no-reply) <${{ secrets.EMAIL_USERNAME }}>"
|
||||
to: ${{ secrets.EMAIL_USERNAME }}
|
||||
username: ${{ secrets.EMAIL_USERNAME }}
|
||||
password: ${{ secrets.EMAIL_PASSWORD }}
|
||||
|
|
@ -60,4 +67,7 @@ jobs:
|
|||
For more details, please check:
|
||||
- GitHub Actions run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
${{ steps.pr-url.outputs.pr_url && format('- Pull Request: {0}', steps.pr-url.outputs.pr_url) }}
|
||||
- Commit: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}
|
||||
|
||||
- name: ❌ Mark workflow failed on Linting failure
|
||||
if: steps.lint.outcome == 'failure'
|
||||
run: exit 1
|
||||
|
|
|
|||
30
.github/workflows/type-check.yml
vendored
|
|
@ -5,6 +5,10 @@ on:
|
|||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
type-check:
|
||||
name: Run type check
|
||||
|
|
@ -12,13 +16,13 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# we use pinned versions because there are safer to use: https://x.com/paulmillr/status/1900948425325031448
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
name: 🛎️ Checkout repository
|
||||
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
name: 📦 Setup pnpm
|
||||
|
||||
- uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # 4.3.0
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
name: 📚 Setup Node.js
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
|
@ -28,10 +32,12 @@ jobs:
|
|||
run: pnpm install
|
||||
|
||||
- name: 🔍 Run type check
|
||||
id: type-check
|
||||
continue-on-error: true
|
||||
run: pnpm type-check:go
|
||||
|
||||
- name: 🔍 Get PR URL
|
||||
if: failure()
|
||||
if: steps.type-check.outcome == 'failure'
|
||||
id: pr-url
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -40,16 +46,17 @@ jobs:
|
|||
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/pulls?head=${{ github.repository_owner }}:${{ github.ref_name }}" \
|
||||
| jq -r '.[0].html_url')
|
||||
| jq -r '.[0].html_url // empty')
|
||||
echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 📧 Send email on failure
|
||||
if: failure()
|
||||
uses: dawidd6/action-send-mail@611879133a9569642c41be66f4a323286e9b8a3b # v4
|
||||
- name: 📧 Send email
|
||||
if: steps.type-check.outcome == 'failure'
|
||||
# https://github.com/dawidd6/action-send-mail
|
||||
uses: dawidd6/action-send-mail@d38f3f7cd391cdebfe0d38efc3998b935e951c4f # v16
|
||||
with:
|
||||
server_address: smtp.gmail.com
|
||||
server_port: 587
|
||||
from: GitHub Actions
|
||||
from: "GitHub Actions (no-reply) <${{ secrets.EMAIL_USERNAME }}>"
|
||||
to: ${{ secrets.EMAIL_USERNAME }}
|
||||
username: ${{ secrets.EMAIL_USERNAME }}
|
||||
password: ${{ secrets.EMAIL_PASSWORD }}
|
||||
|
|
@ -60,4 +67,7 @@ jobs:
|
|||
For more details, please check:
|
||||
- GitHub Actions run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
${{ steps.pr-url.outputs.pr_url && format('- Pull Request: {0}', steps.pr-url.outputs.pr_url) }}
|
||||
- Commit: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}
|
||||
|
||||
- name: ❌ Mark workflow failed on Type Check failure
|
||||
if: steps.type-check.outcome == 'failure'
|
||||
run: exit 1
|
||||
|
|
|
|||
29
.github/workflows/unit-tests.yml
vendored
|
|
@ -5,6 +5,10 @@ on:
|
|||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
name: Run unit tests
|
||||
|
|
@ -12,13 +16,13 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# we use pinned versions because there are safer to use: https://x.com/paulmillr/status/1900948425325031448
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
name: 🛎️ Checkout repository
|
||||
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
name: 📦 Setup pnpm
|
||||
|
||||
- uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # 4.3.0
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
name: 📚 Setup Node.js
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
|
@ -30,9 +34,10 @@ jobs:
|
|||
- name: 🧪 Run Vitest tests
|
||||
id: vitest
|
||||
run: pnpm vitest run --reporter=verbose
|
||||
continue-on-error: true
|
||||
|
||||
- name: 🔍 Get PR URL
|
||||
if: failure()
|
||||
if: steps.vitest.outcome == 'failure'
|
||||
id: pr-url
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -41,16 +46,17 @@ jobs:
|
|||
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/pulls?head=${{ github.repository_owner }}:${{ github.ref_name }}" \
|
||||
| jq -r '.[0].html_url')
|
||||
| jq -r '.[0].html_url // empty')
|
||||
echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 📧 Send email on failure
|
||||
if: failure()
|
||||
uses: dawidd6/action-send-mail@611879133a9569642c41be66f4a323286e9b8a3b # v4
|
||||
- name: 📧 Send email
|
||||
if: steps.vitest.outcome == 'failure'
|
||||
# https://github.com/dawidd6/action-send-mail
|
||||
uses: dawidd6/action-send-mail@d38f3f7cd391cdebfe0d38efc3998b935e951c4f # v16
|
||||
with:
|
||||
server_address: smtp.gmail.com
|
||||
server_port: 587
|
||||
from: GitHub Actions
|
||||
from: "GitHub Actions (no-reply) <${{ secrets.EMAIL_USERNAME }}>"
|
||||
to: ${{ secrets.EMAIL_USERNAME }}
|
||||
username: ${{ secrets.EMAIL_USERNAME }}
|
||||
password: ${{ secrets.EMAIL_PASSWORD }}
|
||||
|
|
@ -61,4 +67,7 @@ jobs:
|
|||
For more details, please check:
|
||||
- GitHub Actions run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
${{ steps.pr-url.outputs.pr_url && format('- Pull Request: {0}', steps.pr-url.outputs.pr_url) }}
|
||||
- Commit: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}
|
||||
|
||||
- name: ❌ Mark workflow failed on Unit Tests failure
|
||||
if: steps.vitest.outcome == 'failure'
|
||||
run: exit 1
|
||||
|
|
|
|||
7
.npmrc
|
|
@ -2,4 +2,9 @@ save-exact=true
|
|||
|
||||
# this specifies the number of minutes that must pass after a version is published before pnpm will install it (to prevent supply chain attacks)
|
||||
# https://pnpm.io/settings#minimumreleaseage
|
||||
minimumReleaseAge=4320 # 3 days,
|
||||
|
||||
minimum-release-age=4320 # 3 days,
|
||||
|
||||
# https://pnpm.io/supply-chain-security
|
||||
block-exotic-subdeps=true
|
||||
trust-policy=no-downgrade
|
||||
106
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# Changelog
|
||||
|
||||
## [1.0.3] - 2026-03-29
|
||||
|
||||
### Added
|
||||
|
||||
- Email visibility toggle for seller and buyer sections — control whether the email address appears in the generated PDF
|
||||
- `ConfirmDiscardDialog` component to warn users about unsaved changes when closing the buyer/seller dialogs (replaces native `window.confirm`)
|
||||
- `useConfirmDiscard` reusable hook for managing discard confirmation state across buyer and seller dialogs
|
||||
- Knip CI workflow for automated dead-code and unused-dependency detection
|
||||
- `update-github-actions` script in `package.json` to streamline GitHub Actions version updates
|
||||
- Unit tests for the `generate-invoice` API route and core logic (`generate-invoice.ts`, `route.tsx`)
|
||||
|
||||
### Changed
|
||||
|
||||
- Refactored `generate-invoice` API route: extracted core business logic into a standalone `generate-invoice.ts` module using a dependency-injection pattern for improved testability
|
||||
- Reworked seller and buyer information form sections with improved layout, locked-state banners, and cleaner field grouping
|
||||
- Buyer and seller dialogs now reset form values and pre-fill switch to their defaults when closed
|
||||
- Buyer and seller names are trimmed of whitespace before saving; whitespace-padded duplicates are rejected
|
||||
- Invalid localStorage entries for buyers and sellers are now validated and silently dropped instead of causing errors
|
||||
- Out-of-Date dates helper improved with more accurate state detection
|
||||
- Error message component layout and copy updated for better readability
|
||||
- Vitest config updated with JSX support (`esbuild.jsx: "automatic"`) to enable unit-testing JSX components
|
||||
- Restructured buyer and seller dialog components into dedicated feature directories under `sections/components/buyer` and `sections/components/seller`
|
||||
- GitHub Actions workflows updated to latest action versions; failure handling added to all CI jobs
|
||||
- Auto-scroll the invoice form on mobile when switching between tabs
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pre-fill switch in buyer/seller dialogs no longer retains its state after the dialog is closed and reopened
|
||||
- Rate limit exceeded log upgraded from `console.log` to `console.error` for correct severity
|
||||
- Loading placeholder display fixed when switching invoice tabs on mobile
|
||||
|
||||
## [1.0.2] - 2026-03-10
|
||||
|
||||
### Added
|
||||
|
||||
- QR code generation for invoices with customizable descriptions and visibility toggles, supported in both default and Stripe templates
|
||||
- Logo upload for the default invoice template (previously available only in the Stripe template)
|
||||
- Searchable currency combobox with grouped categories, replacing the native dropdown for faster selection
|
||||
- Improved multi-page PDF support with automatic pagination and page breaks
|
||||
|
||||
### Changed
|
||||
|
||||
- Increased QR code size and improved rendering quality for better scannability
|
||||
- Enhanced invoice template text color and visuals for improved readability
|
||||
- Reorganized Stripe payment link input position in the form for better flow
|
||||
- Improved user feedback during invoice item deletion with better toast notification handling
|
||||
- Enhanced error handling to reset invoice metadata to defaults on errors
|
||||
- Clearer error messages when invoice sharing fails
|
||||
- Tooltip on the "Add invoice item" button for contextual guidance
|
||||
- Sentry error tracking integration for invoice sharing and GitHub stars features
|
||||
|
||||
### Fixed
|
||||
|
||||
- i18n issue when generating PDF via the API route
|
||||
- Delete invoice item flow not working correctly
|
||||
- Item name field validation too strict (now optional for flexibility)
|
||||
|
||||
## [1.0.1] - 2026-01-12
|
||||
|
||||
### Added
|
||||
|
||||
- Stripe-inspired invoice template with professional styling and layout optimizations
|
||||
- Dynamic template selection in the invoice form
|
||||
- Logo upload capability for the Stripe template with validation
|
||||
- Stripe payment URL field for enhanced invoice functionality
|
||||
- Customizable Tax/VAT label text (e.g., "VAT", "GST", "Sales Tax")
|
||||
- Customizable Tax Number label in buyer and seller information sections
|
||||
- Dynamic tax label updates based on selected invoice language
|
||||
|
||||
### Changed
|
||||
|
||||
- Landing page cleanup: refined About section and footer for better layout and accessibility
|
||||
- Call-to-action toasts: added custom, randomized CTA toasts encouraging user support
|
||||
- Added support for more currencies with improved date handling
|
||||
- Enhanced tooltips with detailed explanations and improved styling
|
||||
- Enhanced validation for VAT input to accept both numeric values and specific strings
|
||||
- Improved user interface messages for clarity regarding VAT input requirements
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bug with accordion component
|
||||
- Error message for invoice link generation now includes a refresh suggestion
|
||||
|
||||
## [1.0.0] - 2025-11-19
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release of EasyInvoicePDF — a free, open-source invoice generator
|
||||
- Live preview: invoice updates in real-time as you make changes
|
||||
- Shareable links: generate secure links to share invoices directly with clients
|
||||
- Instant PDF download with one click
|
||||
- Multi-language support (English, Polish, German, Spanish, Portuguese, Russian, Ukrainian, French, Italian, Dutch)
|
||||
- Support for all major currencies with automatic locale-based formatting
|
||||
- European VAT calculation and formatting compliant with EU tax requirements
|
||||
- Complete seller and buyer information management with save-for-future-use
|
||||
- Detailed invoice items with descriptions, quantities, and pricing
|
||||
- Automatic tax calculations and totals
|
||||
- Invoice numbering, dating, and payment terms
|
||||
- No sign-up required — fully browser-based with no server-side data storage
|
||||
|
||||
[1.0.3]: https://github.com/VladSez/easy-invoice-pdf/compare/v1.0.2...v1.0.3
|
||||
[1.0.2]: https://github.com/VladSez/easy-invoice-pdf/compare/EasyInvoicePDF-1.0.1...v1.0.2
|
||||
[1.0.1]: https://github.com/VladSez/easy-invoice-pdf/compare/EasyInvoicePDF-v1.0.0...EasyInvoicePDF-1.0.1
|
||||
[1.0.0]: https://github.com/VladSez/easy-invoice-pdf/releases/tag/EasyInvoicePDF-v1.0.0
|
||||
152
README.md
|
|
@ -1,5 +1,8 @@
|
|||
<div align="center">
|
||||
<img src=".github/screenshots/easy-invoice-logo-readme.png" alt="EasyInvoicePDF Logo" width="160" height="160">
|
||||
<!-- source: .github/screenshots/easy-invoice-logo-readme.png -->
|
||||
<a href="https://easyinvoicepdf.com/?ref=github">
|
||||
<img src="https://github.com/user-attachments/assets/cb9bcc91-b4c8-40b1-b406-bc606c5d9315" alt="EasyInvoicePDF Logo" width="80" height="80">
|
||||
</a>
|
||||
<h1>EasyInvoicePDF</h1>
|
||||
<h3>Free & Open-Source Invoice Generator</h3>
|
||||
<p>Create professional invoices instantly in your browser with <strong>Live Preview</strong>, <strong>Multiple Templates</strong> (including a Stripe-style design). <strong>No Sign-Up Required</strong>.</p>
|
||||
|
|
@ -9,21 +12,27 @@
|
|||
·
|
||||
<a href="https://github.com/VladSez/easy-invoice-pdf/releases">Releases</a>
|
||||
</p>
|
||||
|
||||
<a href="https://easyinvoicepdf.com/?template=stripe&ref=github">
|
||||
<img width="1440" height="769" alt="EasyInvoicePDF Product Screenshot" src=".github/screenshots/stripe-template.png" />
|
||||
<!-- source: .github/screenshots/stripe-template.png -->
|
||||
<img width="1920" height="1536" alt="EasyInvoicePDF Product Screenshot" src="https://github.com/user-attachments/assets/6f0e2156-24e0-4f8b-b8f1-1d2bf5ac157a" />
|
||||
</a>
|
||||
|
||||
<hr/>
|
||||
<!-- source: .github/demos/easy-invoice-github-demo.gif -->
|
||||
<img src="https://github.com/user-attachments/assets/450fcdc8-32fc-4f41-bc4b-54d6ac96e03c" width="1440" alt="EasyInvoicePDF demo">
|
||||
</div>
|
||||
|
||||
## ✨ Key Features of EasyInvoicePDF:
|
||||
|
||||
- 📺 **Live PDF Preview**: See changes in real-time as you type
|
||||
- ⭐ **No Sign-Up Required**: Start creating invoices immediately without any registration
|
||||
- 📄 **Instant PDF**: One-click download ready for printing or sending
|
||||
- ⚡ **Live Preview**: See changes in real-time as you type
|
||||
- 🔗 **Shareable Links**: Send invoices directly to clients without attachments
|
||||
- 🎨 **Multiple Templates**: Including modern **Stripe-style design**
|
||||
- 📱 **Browser Only**: No server uploads, your data stays private
|
||||
- 💰 **Flexible Tax Support**: VAT, GST, Sales Tax, and custom tax formats with automatic calculations
|
||||
- 🌍 **Multi-Language & Currency**: Support for 10+ languages and 120+ currencies
|
||||
- 🖥️ **Browser Only**: No server uploads, your data stays private
|
||||
- 💰 **Flexible Tax Support**: VAT, GST, Sales Tax, and custom tax formats with automatic calculations
|
||||
- 📱 **Mobile-Friendly**: Create invoices on the go from any device
|
||||
- 🏞️ **QR Code Support**: Add payment QR codes with any invoice-related information (payment links, UPI, contact details, custom data)
|
||||
- 📑 **Multi-Page PDFs**: Seamless multi-page support with automatic pagination and page breaks
|
||||
|
|
@ -36,7 +45,9 @@
|
|||
|
||||
### 🎬 Invoice PDF Live Preview
|
||||
|
||||
<img src=".github/demos/live-preview.gif" width="800" alt="Live Preview Demo">
|
||||
<!-- source: .github/demos/live-pdf-preview.gif -->
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/bddb4b73-630a-4bc5-981b-d470971cd4c6" width="800" alt="Live Preview Demo">
|
||||
|
||||
_See changes in **real-time** as you type_
|
||||
|
||||
|
|
@ -44,65 +55,120 @@ _See changes in **real-time** as you type_
|
|||
|
||||
### 📥 Instant PDF Download
|
||||
|
||||
<img src=".github/demos/instant-download.gif" width="800" alt="Instant Download Demo">
|
||||
<!-- source: .github/demos/instant-download.gif -->
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/d85710c5-ac72-4d80-9c43-aeef980d0734" width="800" alt="Instant Download Demo">
|
||||
|
||||
_**One-click PDF download** ready for printing or sending_
|
||||
|
||||
---
|
||||
|
||||
### 🔗 Shareable Links
|
||||
|
||||
<img src=".github/demos/share-link.gif" width="800" alt="Shareable Links Demo">
|
||||
|
||||
_**Send invoices directly to clients** without attachments_
|
||||
|
||||
---
|
||||
|
||||
### 📲 QR Codes & Advanced Multi-Page PDF Support
|
||||
|
||||
<img src=".github/demos/qr-code.gif" width="800" alt="QR Code Support Demo">
|
||||
|
||||
_**Add payment QR codes** with any invoice-related information (payment links, UPI, contact details, custom data) and **seamless multi-page support** with automatic pagination and page breaks for large invoices_
|
||||
|
||||
---
|
||||
|
||||
### 🏷️ Customizable Tax Settings
|
||||
|
||||
<img src=".github/demos/tax-custom.gif" width="800" alt="Customizable Tax Settings Demo">
|
||||
|
||||
_**Customize tax labels** (VAT, Sales Tax, IVA, etc.)_
|
||||
|
||||
---
|
||||
|
||||
### 🌍 Language & Currency
|
||||
|
||||
<img src=".github/demos/lang-currency.gif" width="800" alt="Language & Currency Demo">
|
||||
<!-- source: .github/demos/lang-currency.gif -->
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/719f74a1-9be0-4b95-a8c1-a2749246aff3" width="800" alt="Language & Currency Demo">
|
||||
|
||||
_**Switch between 10 languages and 120+ currencies instantly** with live PDF preview updates_
|
||||
|
||||
---
|
||||
|
||||
### 🎨 Professional Invoice Templates
|
||||
### 🔗 Shareable Links
|
||||
|
||||
<img src=".github/demos/invoice-template.gif" width="800" alt="Invoice Templates Demo">
|
||||
<!-- source: .github/demos/shared-links.gif -->
|
||||
|
||||
_**Choose between multiple professional templates** (Default and Stripe) to match your brand and style_
|
||||
<img src="https://github.com/user-attachments/assets/78f3c560-8dde-4f73-8832-4d74a38a2cee" width="800" alt="Shareable Links Demo">
|
||||
|
||||
| Default Invoice Template | Stripe Invoice Template |
|
||||
| :-----------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------: |
|
||||
| <img src=".github/screenshots/default-template.png" width="1200" height="960" alt="Default Invoice Template"> | <img src=".github/screenshots/stripe-template.png" width="1200" height="960" alt="Stripe Invoice Template"> |
|
||||
_**Send invoices directly to clients** without attachments_
|
||||
|
||||
---
|
||||
|
||||
### 💰 Customizable Tax Settings
|
||||
|
||||
<!-- source: .github/demos/tax-custom.gif -->
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/dab10991-6e76-4851-ab4f-25f52da2b6ed" width="800" alt="Customizable Tax Settings Demo">
|
||||
|
||||
_**Customize tax labels** (VAT, Sales Tax, IVA, etc.)_
|
||||
|
||||
---
|
||||
|
||||
### 🏞️ QR Codes & Advanced Multi-Page PDF Support
|
||||
|
||||
<!-- source: .github/demos/qr-code.gif -->
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/2baf6c39-16a8-47c2-8f08-d03de6d9e593" width="800" alt="QR Code Support Demo">
|
||||
|
||||
_**Add payment QR codes** with any invoice-related information (payment links, UPI, contact details, custom data) and **seamless multi-page support** with automatic pagination and page breaks for large invoices_
|
||||
|
||||
---
|
||||
|
||||
<!-- source: .github/screenshots/default-template.png -->
|
||||
<!-- source: .github/screenshots/stripe-template.png -->
|
||||
|
||||
| Default Invoice Template | Stripe Invoice Template |
|
||||
| :--------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------: |
|
||||
| <img src="https://github.com/user-attachments/assets/7779cd52-e2a9-442a-8ac8-262387319b3e" width="1200" height="960" alt="Default Invoice Template"> | <img src="https://github.com/user-attachments/assets/ee8d5337-89fc-461c-b1c1-2680287df514" width="1200" height="960" alt="Stripe Invoice Template"> |
|
||||
|
||||
</div>
|
||||
|
||||
## 📢 What's New
|
||||
|
||||
### EasyInvoicePDF v1.0.3 — Seller & Buyer Improvements (March 29, 2026)
|
||||
|
||||
- **Seller & Buyer Email visibility toggle** — control whether email addresses appear in the generated PDF
|
||||
- **Confirm discard dialog** — warns about unsaved changes when closing buyer/seller dialogs
|
||||
- **Improved seller & buyer forms** — reworked layout, locked-state banners, and cleaner field grouping
|
||||
- **Out-of-Date dates helper** shows outdated fields and provides a button to update all dates at once
|
||||
- **Auto-scroll (to the last position) the invoice form on mobile** when switching between tabs (UX improvement)
|
||||
|
||||
https://github.com/user-attachments/assets/1b39eb6f-e2be-493f-9825-cbce3dc6fa16
|
||||
|
||||
[Full release notes for v1.0.3](https://github.com/VladSez/easy-invoice-pdf/releases/tag/v1.0.3)
|
||||
|
||||
---
|
||||
|
||||
### EasyInvoicePDF v1.0.2 — QR Codes & Multi-Page PDFs (March 10, 2026)
|
||||
|
||||
- **QR code support** — add payment QR codes with custom descriptions to both templates
|
||||
- **Logo upload for default template** — add a logo to the default invoice template
|
||||
- **Searchable currency combobox** — search by currency code, symbol, or name, grouped into categories replacing the native dropdown
|
||||
- **Improved multi-page PDFs** — automatic pagination and page breaks for large invoices
|
||||
|
||||

|
||||
|
||||
[Full release notes for v1.0.2](https://github.com/VladSez/easy-invoice-pdf/releases/tag/v1.0.2)
|
||||
|
||||
---
|
||||
|
||||
### EasyInvoicePDF v1.0.1 — Customizable Tax/VAT Labels & Major Improvements (January 12, 2026)
|
||||
|
||||
- **Customizable tax labels** — set VAT, GST, Sales Tax, or any custom label per invoice language
|
||||
- **Improved i18n** — dynamic tax label updates and better locale-based currency handling
|
||||
- **Enhanced VAT validation** — accepts numeric values and specific strings
|
||||
|
||||
https://github.com/user-attachments/assets/4eef2b90-678b-4a55-9ee5-8fcf195c993a
|
||||
|
||||
[Full release notes for v1.0.1](https://github.com/VladSez/easy-invoice-pdf/releases/tag/EasyInvoicePDF-1.0.1)
|
||||
|
||||
---
|
||||
|
||||
### EasyInvoicePDF v1.0.0 — Initial Release (November 19, 2025)
|
||||
|
||||
- **Live preview** — invoice updates in real-time as you type
|
||||
- **Instant PDF download** — one-click, no sign-up required
|
||||
- **Default and Stripe-inspired invoice templates** — choose the look you want
|
||||
- **Shareable links** — send invoices directly to clients without attachments
|
||||
- **10 languages & 120+ currencies** — full multi-language and currency support out of the box
|
||||
|
||||
https://github.com/user-attachments/assets/23bb5448-c9fb-4ff2-98f3-0b80d75b7683
|
||||
|
||||
[Full release notes for v1.0.0](https://github.com/VladSez/easy-invoice-pdf/releases/tag/EasyInvoicePDF-v1.0.0)
|
||||
|
||||
## 🌟 Star History
|
||||
|
||||
[](https://repostars.dev/?repos=VladSez%2Feasy-invoice-pdf&theme=dark)
|
||||
|
||||
## 📢 News & Updates
|
||||
|
||||
- **Jan 11, 2026**: Added customizable tax/VAT labels, improved internationalization (i18n) translations, enhanced overall performance, and fixed multiple bugs. [Release notes for v1.0.1](https://github.com/VladSez/easy-invoice-pdf/releases/tag/EasyInvoicePDF-1.0.1)
|
||||
- **Nov 19, 2025**: EasyInvoicePDF version 1.0.0 released! Create professional invoices in seconds. Welcome to try EasyInvoicePDF. [Release notes for v1.0.0](https://github.com/VladSez/easy-invoice-pdf/releases/tag/EasyInvoicePDF-v1.0.0)
|
||||
|
||||
## 🎥 Demo Video
|
||||
|
||||
Watch a quick demo of EasyInvoicePDF in action to see how easy it is to create professional invoices in seconds. The video demonstrates key features like **Live Preview**, **Instant PDF Download**, and **Customization Options**.
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import {
|
||||
GITHUB_URL,
|
||||
TWITTER_URL,
|
||||
VIDEO_DEMO_FALLBACK_IMG,
|
||||
VIDEO_DEMO_URL,
|
||||
} from "@/config";
|
||||
/* eslint-disable playwright/no-conditional-expect */
|
||||
/* eslint-disable playwright/no-conditional-in-test */
|
||||
import { GITHUB_URL, TWITTER_URL, VIDEO_DEMO_FALLBACK_IMG } from "@/config";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("About page", () => {
|
||||
test("should display about page content in English", async ({ page }) => {
|
||||
test("should display about page content in English", async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
await page.goto("/en/about");
|
||||
|
||||
// Verify the page is loaded
|
||||
|
|
@ -21,16 +21,67 @@ test.describe("About page", () => {
|
|||
|
||||
/* CHECK HEADER ELEMENTS */
|
||||
|
||||
// Check language switcher button in header
|
||||
const languageSwitcher = header.getByRole("button", {
|
||||
name: "Switch language",
|
||||
});
|
||||
await expect(languageSwitcher).toBeVisible();
|
||||
if (isMobile) {
|
||||
// Mobile: burger button visible, nav links and language switcher hidden in header
|
||||
await expect(
|
||||
header.getByRole("button", { name: "Open menu" }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
header.getByRole("button", { name: "Switch language" }),
|
||||
).toBeHidden();
|
||||
|
||||
await expect(
|
||||
header.getByRole("link", { name: "Features", exact: true }),
|
||||
).toBeHidden();
|
||||
} else {
|
||||
// Desktop: nav links and language switcher visible inline, burger button hidden
|
||||
await expect(
|
||||
header.getByRole("button", { name: "Switch language" }),
|
||||
).toBeVisible();
|
||||
|
||||
const featuresLink = header.getByRole("link", {
|
||||
name: "Features",
|
||||
exact: true,
|
||||
});
|
||||
|
||||
await expect(featuresLink).toBeVisible();
|
||||
await expect(featuresLink).toHaveAttribute("href", "/en/about#features");
|
||||
|
||||
const faqLink = header.getByRole("link", { name: "FAQ", exact: true });
|
||||
|
||||
await expect(faqLink).toBeVisible();
|
||||
await expect(faqLink).toHaveAttribute("href", "/en/about#faq");
|
||||
|
||||
const changelogLink = header.getByRole("link", {
|
||||
name: "Changelog",
|
||||
exact: true,
|
||||
});
|
||||
|
||||
await expect(changelogLink).toBeVisible();
|
||||
await expect(changelogLink).toHaveAttribute("href", "/changelog");
|
||||
|
||||
const githubLink = header.getByRole("link", {
|
||||
name: "View on GitHub",
|
||||
exact: true,
|
||||
});
|
||||
|
||||
await expect(githubLink).toBeVisible();
|
||||
await expect(githubLink).toHaveAttribute("href", GITHUB_URL);
|
||||
|
||||
// hidden on desktop
|
||||
await expect(
|
||||
header.getByRole("button", { name: "Open menu" }),
|
||||
).toBeHidden();
|
||||
}
|
||||
|
||||
// check app link button in header
|
||||
await expect(header.getByText("EasyInvoicePDF")).toBeVisible();
|
||||
await expect(
|
||||
header.getByRole("link", { name: "EasyInvoicePDF" }),
|
||||
).toBeVisible();
|
||||
|
||||
const goToAppButton = header.getByRole("link", {
|
||||
name: "Go to app",
|
||||
name: "Open app",
|
||||
exact: true,
|
||||
});
|
||||
await expect(goToAppButton).toBeVisible();
|
||||
|
|
@ -61,15 +112,7 @@ test.describe("About page", () => {
|
|||
await expect(video).toHaveAttribute("muted");
|
||||
await expect(video).toHaveAttribute("loop");
|
||||
await expect(video).toHaveAttribute("playsinline");
|
||||
await expect(video).toHaveAttribute("preload", "auto");
|
||||
await expect(video).toHaveAttribute("autoplay");
|
||||
|
||||
const videoSource = video.locator("source");
|
||||
await expect(videoSource).toHaveAttribute(
|
||||
"src",
|
||||
`${VIDEO_DEMO_URL}#t=0.001`,
|
||||
);
|
||||
await expect(videoSource).toHaveAttribute("type", "video/mp4");
|
||||
await expect(video).toHaveAttribute("preload", "none");
|
||||
|
||||
// Check Features section
|
||||
const featuresSection = page.locator("#features");
|
||||
|
|
@ -169,6 +212,24 @@ test.describe("About page", () => {
|
|||
await expect(changelogLink).toHaveAttribute("href", "/changelog");
|
||||
await expect(changelogLink).not.toHaveAttribute("target", "_blank");
|
||||
|
||||
const founderLink = footerLinks.getByRole("link", {
|
||||
name: "Founder",
|
||||
exact: true,
|
||||
});
|
||||
|
||||
await expect(founderLink).toBeVisible();
|
||||
await expect(founderLink).toHaveAttribute("href", "/founder");
|
||||
await expect(founderLink).not.toHaveAttribute("target", "_blank");
|
||||
|
||||
const termsOfServiceLink = footerLinks.getByRole("link", {
|
||||
name: "Terms of Service",
|
||||
exact: true,
|
||||
});
|
||||
|
||||
await expect(termsOfServiceLink).toBeVisible();
|
||||
await expect(termsOfServiceLink).toHaveAttribute("href", "/tos");
|
||||
await expect(termsOfServiceLink).not.toHaveAttribute("target", "_blank");
|
||||
|
||||
const shareFeedbackLink = footerLinks.getByRole("link", {
|
||||
name: "Share feedback",
|
||||
exact: true,
|
||||
|
|
@ -201,7 +262,9 @@ test.describe("About page", () => {
|
|||
|
||||
const header = page.getByRole("banner");
|
||||
// Check header elements in French
|
||||
await expect(header.getByText("EasyInvoicePDF")).toBeVisible();
|
||||
await expect(
|
||||
header.getByRole("link", { name: "EasyInvoicePDF" }),
|
||||
).toBeVisible();
|
||||
|
||||
const goToAppButton = header.getByRole("link", {
|
||||
name: "Ouvrir",
|
||||
|
|
@ -266,6 +329,15 @@ test.describe("About page", () => {
|
|||
await expect(featuresLink).toBeVisible();
|
||||
await expect(featuresLink).toHaveAttribute("href", "#features");
|
||||
await expect(featuresLink).not.toHaveAttribute("target", "_blank");
|
||||
|
||||
const termsOfServiceLinkFr = footerLinks.getByRole("link", {
|
||||
name: "Conditions d'utilisation",
|
||||
exact: true,
|
||||
});
|
||||
|
||||
await expect(termsOfServiceLinkFr).toBeVisible();
|
||||
await expect(termsOfServiceLinkFr).toHaveAttribute("href", "/tos");
|
||||
await expect(termsOfServiceLinkFr).not.toHaveAttribute("target", "_blank");
|
||||
});
|
||||
|
||||
test("should display about page content in German", async ({ page }) => {
|
||||
|
|
@ -276,7 +348,10 @@ test.describe("About page", () => {
|
|||
|
||||
const header = page.getByRole("banner");
|
||||
// Check header elements in German
|
||||
await expect(header.getByText("EasyInvoicePDF")).toBeVisible();
|
||||
await expect(
|
||||
header.getByRole("link", { name: "EasyInvoicePDF" }),
|
||||
).toBeVisible();
|
||||
|
||||
const goToAppButton = header.getByRole("link", {
|
||||
name: "Öffnen",
|
||||
exact: true,
|
||||
|
|
@ -325,27 +400,41 @@ test.describe("About page", () => {
|
|||
await expect(
|
||||
footerLinks.getByRole("link", { name: "Funktionen", exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
const termsOfServiceLinkDe = footerLinks.getByRole("link", {
|
||||
name: "Nutzungsbedingungen",
|
||||
exact: true,
|
||||
});
|
||||
|
||||
await expect(termsOfServiceLinkDe).toBeVisible();
|
||||
await expect(termsOfServiceLinkDe).toHaveAttribute("href", "/tos");
|
||||
await expect(termsOfServiceLinkDe).not.toHaveAttribute("target", "_blank");
|
||||
});
|
||||
|
||||
test("should handle language switching", async ({ page }) => {
|
||||
test("should handle language switching", async ({ page, isMobile }) => {
|
||||
// Start with English
|
||||
await page.goto("/en/about");
|
||||
await expect(page).toHaveURL("/en/about");
|
||||
|
||||
// Switch to French
|
||||
// On mobile, open the mobile menu first
|
||||
if (isMobile) await page.getByRole("button", { name: "Open menu" }).click();
|
||||
|
||||
// Then switch to French
|
||||
await page
|
||||
.getByRole("button", { name: "Switch language", exact: true })
|
||||
.click();
|
||||
await page.getByText("Français").click();
|
||||
|
||||
await expect(page).toHaveURL("/fr/about");
|
||||
|
||||
const header = page.getByRole("banner");
|
||||
|
||||
await expect(
|
||||
header.getByRole("link", {
|
||||
name: "Ouvrir",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL("/fr/about");
|
||||
});
|
||||
|
||||
test("should navigate to app when clicking Go to App button", async ({
|
||||
|
|
@ -358,11 +447,135 @@ test.describe("About page", () => {
|
|||
const header = page.getByRole("banner");
|
||||
|
||||
const headerGoToAppButton = header.getByRole("link", {
|
||||
name: "Go to app",
|
||||
name: "Open app",
|
||||
exact: true,
|
||||
});
|
||||
|
||||
await headerGoToAppButton.click();
|
||||
await expect(page).toHaveURL("/?template=default");
|
||||
});
|
||||
|
||||
test("should show desktop nav links in header (ON MOBILE TEST WILL BE SKIPPED)", async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
// skip test on mobile
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(isMobile, "Desktop nav only exists on desktop viewport");
|
||||
|
||||
await page.goto("/en/about");
|
||||
|
||||
const header = page.getByRole("banner");
|
||||
|
||||
await expect(
|
||||
header.getByRole("link", { name: "Features", exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
header.getByRole("link", { name: "FAQ", exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
header.getByRole("link", { name: "Changelog", exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
header.getByRole("link", { name: "Terms of Service", exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
header.getByRole("link", { name: "View on GitHub", exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
// we don't show founder link in header
|
||||
await expect(
|
||||
header.getByRole("link", { name: "Founder", exact: true }),
|
||||
).toBeHidden();
|
||||
|
||||
await expect(
|
||||
header.getByRole("button", { name: "Switch language" }),
|
||||
).toBeVisible();
|
||||
|
||||
// hidden on desktop
|
||||
await expect(
|
||||
header.getByRole("button", { name: "Open menu" }),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test("should show mobile menu with nav links and language switcher (ON DESKTOP TEST WILL BE SKIPPED)", async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
// skip test on desktop
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!isMobile, "Mobile menu only exists on mobile viewport");
|
||||
|
||||
await page.goto("/en/about");
|
||||
|
||||
const header = page.getByRole("banner");
|
||||
const burgerButton = header.getByRole("button", { name: "Open menu" });
|
||||
|
||||
// Burger button visible on MOBILE, nav links and language switcher not visible in header
|
||||
await expect(burgerButton).toBeVisible();
|
||||
|
||||
await expect(
|
||||
header.getByRole("link", { name: "Features", exact: true }),
|
||||
).toBeHidden();
|
||||
|
||||
await expect(
|
||||
header.getByRole("button", { name: "Switch language" }),
|
||||
).toBeHidden();
|
||||
|
||||
// Open the mobile menu
|
||||
await burgerButton.click();
|
||||
|
||||
const sheet = page.getByRole("dialog", { name: "Mobile Menu" });
|
||||
|
||||
const featuresLink = sheet.getByRole("link", {
|
||||
name: "Features",
|
||||
exact: true,
|
||||
});
|
||||
await expect(featuresLink).toBeVisible();
|
||||
await expect(featuresLink).toHaveAttribute("href", "/en/about#features");
|
||||
|
||||
const faqLink = sheet.getByRole("link", { name: "FAQ", exact: true });
|
||||
await expect(faqLink).toBeVisible();
|
||||
await expect(faqLink).toHaveAttribute("href", "/en/about#faq");
|
||||
|
||||
const changelogLink = sheet.getByRole("link", {
|
||||
name: "Changelog",
|
||||
exact: true,
|
||||
});
|
||||
await expect(changelogLink).toBeVisible();
|
||||
await expect(changelogLink).toHaveAttribute("href", "/changelog");
|
||||
|
||||
const termsLinkMobile = sheet.getByRole("link", {
|
||||
name: "Terms of Service",
|
||||
exact: true,
|
||||
});
|
||||
await expect(termsLinkMobile).toBeVisible();
|
||||
await expect(termsLinkMobile).toHaveAttribute("href", "/tos");
|
||||
|
||||
const githubLink = sheet.getByRole("link", {
|
||||
name: "View on GitHub",
|
||||
exact: true,
|
||||
});
|
||||
await expect(githubLink).toBeVisible();
|
||||
await expect(githubLink).toHaveAttribute("href", GITHUB_URL);
|
||||
|
||||
// we don't show founder link in mobile menu
|
||||
const founderLinkMobile = sheet.getByRole("link", {
|
||||
name: "Founder",
|
||||
exact: true,
|
||||
});
|
||||
await expect(founderLinkMobile).toBeHidden();
|
||||
|
||||
await expect(
|
||||
sheet.getByRole("button", { name: "Switch language" }),
|
||||
).toBeVisible();
|
||||
|
||||
// Close the menu and verify burger button is accessible again
|
||||
await sheet.getByRole("button", { name: "Close menu" }).click();
|
||||
await expect(burgerButton).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
|
|||