@minhduydev/mdpi 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1 -1
- package/dist/template/.pi/VERSION +1 -1
- package/dist/template/.pi/extensions/templates-injector.ts +34 -6
- package/dist/template/.pi/prompts/INDEX.md +3 -9
- package/dist/template/.pi/skills/INDEX.md +81 -19
- package/dist/template/.pi/skills/accessibility-audit/SKILL.md +8 -2
- package/dist/template/.pi/skills/baseline-ui/SKILL.md +211 -0
- package/dist/template/.pi/skills/dcp-hygiene/SKILL.md +1 -1
- package/dist/template/.pi/skills/design-taste-frontend/SKILL.md +53 -42
- package/dist/template/.pi/skills/fixing-accessibility/SKILL.md +509 -0
- package/dist/template/.pi/skills/frontend-design/SKILL.md +60 -47
- package/dist/template/.pi/skills/frontend-design/references/animation/motion-advanced.md +88 -15
- package/dist/template/.pi/skills/frontend-design/references/animation/motion-core.md +148 -13
- package/dist/template/.pi/skills/frontend-design/references/shadcn/setup.md +127 -20
- package/dist/template/.pi/skills/frontend-ui-engineering/SKILL.md +21 -27
- package/dist/template/.pi/skills/nextjs-app-router/SKILL.md +334 -0
- package/dist/template/.pi/skills/nextjs-cache/SKILL.md +262 -0
- package/dist/template/.pi/skills/oklch-color-workflow/SKILL.md +426 -0
- package/dist/template/.pi/skills/production-hardening/SKILL.md +652 -0
- package/dist/template/.pi/skills/react-best-practices/SKILL.md +79 -1
- package/dist/template/.pi/skills/react-compiler/SKILL.md +237 -0
- package/dist/template/.pi/skills/react-hook-form/SKILL.md +374 -0
- package/dist/template/.pi/skills/react-server-actions/SKILL.md +299 -0
- package/dist/template/.pi/skills/shadcn-ui/SKILL.md +404 -0
- package/dist/template/.pi/skills/tanstack-query/SKILL.md +330 -0
- package/dist/template/.pi/skills/ui-craft-principles/SKILL.md +564 -0
- package/dist/template/.pi/skills/ui-quality-audit/SKILL.md +329 -0
- package/dist/template/.pi/skills/v0/SKILL.md +264 -0
- package/dist/template/.pi/skills/zustand/SKILL.md +333 -0
- package/dist/template/.pi/templates/DESIGN.md +76 -0
- package/dist/template/.pi/workflows/INDEX.md +2 -1
- package/dist/template/.pi/workflows/frontend-feature-workflow.md +343 -0
- package/dist/template/.pi/workflows/quality-loop.md +1 -1
- package/package.json +1 -1
- package/dist/template/.pi/prompts/loop-check.md +0 -87
- package/dist/template/.pi/prompts/loop-init.md +0 -157
- package/dist/template/.pi/prompts/loop-review.md +0 -90
- package/dist/template/.pi/skills/loop-audit/SKILL.md +0 -141
- package/dist/template/.pi/skills/loop-cost/SKILL.md +0 -130
- package/dist/template/.pi/skills/loop-engineering/SKILL.md +0 -175
- package/dist/template/.pi/templates/loop-github-action.yml +0 -162
- package/dist/template/.pi/templates/loop-orchestrator.sh +0 -514
- package/dist/template/.pi/templates/loop-orchestrator.test.ts +0 -332
- package/dist/template/.pi/templates/loop-orchestrator.ts +0 -936
- package/dist/template/.pi/templates/loop-state.json +0 -24
- package/dist/template/.pi/templates/loop-state.md +0 -98
- package/dist/template/.pi/templates/loop-vision.md +0 -110
- /package/dist/template/.pi/templates/{design.md → feature-design.md} +0 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: nextjs-cache
|
|
3
|
+
description: Use when working with Next.js 16 caching — `use cache` directive, cacheLife, cacheTag, revalidation, migration from v15. MUST load before implementing any caching strategy.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Next.js Cache System (Next.js 16)
|
|
7
|
+
|
|
8
|
+
## When to Use
|
|
9
|
+
|
|
10
|
+
- Adding `use cache` to functions or components
|
|
11
|
+
- Configuring cache lifetimes with `cacheLife()`
|
|
12
|
+
- Tagging cache entries for targeted revalidation
|
|
13
|
+
- Migrating from Next.js 15's implicit cache (force-dynamic, fetch cache)
|
|
14
|
+
- Calling `revalidateTag()` / `revalidatePath()` after mutations
|
|
15
|
+
- Using `connection()` for database-aware caching
|
|
16
|
+
|
|
17
|
+
## When NOT to Use
|
|
18
|
+
|
|
19
|
+
- Pages Router projects (no `use cache` support)
|
|
20
|
+
- Next.js 14 or earlier (different cache model)
|
|
21
|
+
- Purely client-side caching (use TanStack Query or SWR)
|
|
22
|
+
|
|
23
|
+
## Core Pattern: `use cache`
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
// app/lib/data.ts
|
|
27
|
+
import { cacheTag } from 'next/cache'
|
|
28
|
+
import { db } from '@/lib/db'
|
|
29
|
+
|
|
30
|
+
export async function getPosts() {
|
|
31
|
+
'use cache'
|
|
32
|
+
cacheTag('posts') // Tag for later revalidation
|
|
33
|
+
|
|
34
|
+
const posts = await db.post.findMany({
|
|
35
|
+
include: { author: true },
|
|
36
|
+
orderBy: { createdAt: 'desc' },
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
return posts
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
// app/posts/page.tsx
|
|
45
|
+
import { getPosts } from '@/lib/data'
|
|
46
|
+
|
|
47
|
+
export default async function PostsPage() {
|
|
48
|
+
const posts = await getPosts() // Cached until revalidated or expired
|
|
49
|
+
return <PostsList posts={posts} />
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## cacheLife — Set TTL
|
|
54
|
+
|
|
55
|
+
```tsx
|
|
56
|
+
import { cacheLife } from 'next/cache'
|
|
57
|
+
|
|
58
|
+
export async function getPopularPosts() {
|
|
59
|
+
'use cache'
|
|
60
|
+
cacheLife('hours') // Revalidate every hour
|
|
61
|
+
cacheTag('popular-posts')
|
|
62
|
+
|
|
63
|
+
return db.post.findMany({ where: { views: { gte: 1000 } } })
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Available `cacheLife` values:
|
|
68
|
+
|
|
69
|
+
| Profile | Duration | Use Case |
|
|
70
|
+
|---------|----------|----------|
|
|
71
|
+
| `'seconds'` | ~1 second | Real-time but deduped |
|
|
72
|
+
| `'minutes'` | ~5 minutes | Frequently changing data |
|
|
73
|
+
| `'hours'` | ~1 hour | Dashboard stats, user profiles |
|
|
74
|
+
| `'days'` | ~1 day | Blog content, static pages |
|
|
75
|
+
| `'weeks'` | ~1 week | Changelogs, documentation |
|
|
76
|
+
| `'max'` | Unlimited | Immutable data (never revalidates automatically) |
|
|
77
|
+
|
|
78
|
+
Custom profiles via `next.config.ts`:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// next.config.ts
|
|
82
|
+
import type { NextConfig } from 'next'
|
|
83
|
+
|
|
84
|
+
const config: NextConfig = {
|
|
85
|
+
cacheLife: {
|
|
86
|
+
frequent: {
|
|
87
|
+
stale: 60, // seconds before background revalidate
|
|
88
|
+
revalidate: 300, // seconds before full re-fetch
|
|
89
|
+
expire: 3600, // seconds before purge
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## cacheTag — Target Revalidation
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
// Tag multiple related caches
|
|
99
|
+
export async function getPost(id: string) {
|
|
100
|
+
'use cache'
|
|
101
|
+
cacheTag(`post-${id}`, 'posts') // Individual + list tag
|
|
102
|
+
|
|
103
|
+
return db.post.findUnique({ where: { id } })
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```tsx
|
|
108
|
+
// app/actions.ts — revalidate after mutation
|
|
109
|
+
'use server'
|
|
110
|
+
|
|
111
|
+
import { revalidateTag, revalidatePath } from 'next/cache'
|
|
112
|
+
|
|
113
|
+
export async function deletePost(id: string) {
|
|
114
|
+
await db.post.delete({ where: { id } })
|
|
115
|
+
|
|
116
|
+
revalidateTag(`post-${id}`) // Revalidate specific post
|
|
117
|
+
revalidateTag('posts') // Revalidate all post lists
|
|
118
|
+
revalidatePath('/posts') // Also revalidate the URL path
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## connection() — Database-Driven Cache
|
|
123
|
+
|
|
124
|
+
`connection()` makes the cache aware of your database connection, speeding up purge:
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
import { connection } from 'next/cache'
|
|
128
|
+
|
|
129
|
+
export async function getPost(id: string) {
|
|
130
|
+
'use cache'
|
|
131
|
+
cacheTag(`post-${id}`)
|
|
132
|
+
connection() // Invalidate when DB connection changes (e.g., deploy)
|
|
133
|
+
|
|
134
|
+
return db.post.findUnique({ where: { id } })
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Call `connection()` at any point in the cached function. Multiple calls deduplicate.
|
|
139
|
+
|
|
140
|
+
## Cacheable vs Non-Cacheable
|
|
141
|
+
|
|
142
|
+
**Can be cached**: Database queries, filesystem reads, fetch to stable APIs, computed values, Component output.
|
|
143
|
+
|
|
144
|
+
**Cannot be cached**: Request objects (`cookies()`, `headers()`), mutable state, random values, real-time data.
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
export async function getUserData() {
|
|
148
|
+
const session = await auth() // ❌ Uses cookies — cannot cache
|
|
149
|
+
|
|
150
|
+
const user = await db.user.findUnique({
|
|
151
|
+
where: { id: session.userId }
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
return user
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**Solution**: Split the function — cache only the data part:
|
|
159
|
+
|
|
160
|
+
```tsx
|
|
161
|
+
export async function getUserData() {
|
|
162
|
+
const session = await auth() // Not cached
|
|
163
|
+
|
|
164
|
+
return getUser(session.userId) // Cached
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function getUser(id: string) {
|
|
168
|
+
'use cache'
|
|
169
|
+
cacheTag(`user-${id}`)
|
|
170
|
+
return db.user.findUnique({ where: { id } })
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Migration from Next.js 15
|
|
175
|
+
|
|
176
|
+
### Before (v15 implicit caching):
|
|
177
|
+
|
|
178
|
+
```tsx
|
|
179
|
+
// Next.js 15
|
|
180
|
+
export const dynamic = 'force-dynamic' // Opt out of caching
|
|
181
|
+
export const revalidate = 3600 // ISR interval
|
|
182
|
+
|
|
183
|
+
// fetch caching
|
|
184
|
+
const data = await fetch(url, { cache: 'no-store' })
|
|
185
|
+
const data = await fetch(url, { next: { revalidate: 60 } })
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### After (v16 explicit caching):
|
|
189
|
+
|
|
190
|
+
```tsx
|
|
191
|
+
// Next.js 16 — everything is dynamic by default
|
|
192
|
+
// To cache, use `use cache`:
|
|
193
|
+
export async function getPage() {
|
|
194
|
+
'use cache'
|
|
195
|
+
cacheLife('hours')
|
|
196
|
+
return db.page.findMany()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// No more fetch cache options — use cache instead:
|
|
200
|
+
const data = await fetch(url) // Always fresh, unless wrapped in use cache
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Quick Migration Table
|
|
204
|
+
|
|
205
|
+
| v15 Pattern | v16 Equivalent |
|
|
206
|
+
|------------|----------------|
|
|
207
|
+
| `fetch(url, { cache: 'no-store' })` | `fetch(url)` — no cache (default) |
|
|
208
|
+
| `fetch(url, { next: { revalidate: 60 } })` | Wrap in `use cache` + `cacheLife` |
|
|
209
|
+
| `export const dynamic = 'force-dynamic'` | Remove — dynamic is default |
|
|
210
|
+
| `export const revalidate = 3600` | `'use cache'` + `cacheLife('hours')` |
|
|
211
|
+
| `revalidatePath('/posts')` | Same API — still works |
|
|
212
|
+
| `revalidateTag('posts')` | Same API — still works with `cacheTag` |
|
|
213
|
+
| `unstable_cache(fn, ['key'])` | `'use cache'` + `cacheTag('key')` |
|
|
214
|
+
|
|
215
|
+
## Revalidation vs Expiration
|
|
216
|
+
|
|
217
|
+
- **Revalidate** (`revalidateTag`/`revalidatePath`): Immediate purge and re-fetch on next request
|
|
218
|
+
- **Expire** (`cacheLife` TTL): Background revalidate, stale data served until fresh data ready
|
|
219
|
+
|
|
220
|
+
```tsx
|
|
221
|
+
// Mutation pattern — revalidate affected caches
|
|
222
|
+
export async function updatePost(id: string, data: PostInput) {
|
|
223
|
+
await db.post.update({ where: { id }, data })
|
|
224
|
+
|
|
225
|
+
revalidateTag(`post-${id}`) // Immediate purge
|
|
226
|
+
// cacheLife handles TTL-based refresh for other entries
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Concurrent Mutations Safety
|
|
231
|
+
|
|
232
|
+
`use cache` functions support deduplication — concurrent requests for the same data share one database call:
|
|
233
|
+
|
|
234
|
+
```tsx
|
|
235
|
+
// Three components call getPosts() on same page
|
|
236
|
+
// → only ONE database query executes
|
|
237
|
+
<PostsList /> // calls getPosts()
|
|
238
|
+
<RecentPosts /> // calls getPosts() — deduped
|
|
239
|
+
<PopularPosts /> // calls getPosts() — deduped
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## Common Pitfalls
|
|
243
|
+
|
|
244
|
+
| Pitfall | Fix |
|
|
245
|
+
|---------|-----|
|
|
246
|
+
| Using `cookies()` inside `'use cache'` | Split function: read cookies outside, pass data into cached function |
|
|
247
|
+
| Forgetting `cacheTag()` | Without tags, cache can only be purged by TTL or full flush |
|
|
248
|
+
| Using `revalidatePath` too broadly | Prefer `revalidateTag` — narrower scope, less CPU |
|
|
249
|
+
| Not invalidating after mutation | Every mutation must revalidate affected caches |
|
|
250
|
+
| `cacheLife` too short for write-heavy data | Use `cacheLife('seconds')` or skip caching for hot data |
|
|
251
|
+
| Caching user-specific data without user-scoping | Include `userId` in `cacheTag`: `cacheTag('user-${id}-posts')` |
|
|
252
|
+
| Assuming cache survives deployment | Add `connection()` to auto-invalidate on deploy |
|
|
253
|
+
|
|
254
|
+
## Verification
|
|
255
|
+
|
|
256
|
+
- [ ] `'use cache'` functions have `cacheTag()` for targeted revalidation
|
|
257
|
+
- [ ] Mutations call `revalidateTag()` or `revalidatePath()`
|
|
258
|
+
- [ ] No `cookies()` or `headers()` inside cached functions
|
|
259
|
+
- [ ] `cacheLife` appropriate for data freshness requirements
|
|
260
|
+
- [ ] `connection()` added for database queries that should invalidate on deploy
|
|
261
|
+
- [ ] v15 `fetch` cache options removed or migrated
|
|
262
|
+
- [ ] User-scoped data uses user-specific cache tags
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: oklch-color-workflow
|
|
3
|
+
description: Complete OKLCH color system workflow — syntax, thresholds, conversion, palette generation, contrast checking, gamut mapping, and Tailwind v4 migration
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# OKLCH Color Workflow
|
|
7
|
+
|
|
8
|
+
## When to Use
|
|
9
|
+
|
|
10
|
+
- When defining design tokens or a color palette for a new project
|
|
11
|
+
- When migrating from HEX/RGB to OKLCH in Tailwind v4
|
|
12
|
+
- When needing wider-gamut colors for modern displays (P3, Rec.2020)
|
|
13
|
+
- When creating accessible color systems with predictable lightness and contrast
|
|
14
|
+
- When building design systems that need scientific, perceptually-uniform color
|
|
15
|
+
|
|
16
|
+
## When NOT to Use
|
|
17
|
+
|
|
18
|
+
- Projects not using CSS or design tokens (native mobile apps without color system migration)
|
|
19
|
+
- When a pre-built palette (Tailwind default colors, shadcn/ui defaults) is sufficient
|
|
20
|
+
- Quick prototypes where color system design is premature
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 1. OKLCH Syntax
|
|
25
|
+
|
|
26
|
+
OKLCH is a perceptually-uniform color space: equal changes in values produce equal changes in perceived color.
|
|
27
|
+
|
|
28
|
+
```css
|
|
29
|
+
/* Syntax: oklch(L C H / alpha) */
|
|
30
|
+
/* L: Lightness — 0 (black) to 1 (white) */
|
|
31
|
+
/* C: Chroma — 0 (gray) to ~0.37 (maximum) */
|
|
32
|
+
/* H: Hue — 0 to 360 degrees */
|
|
33
|
+
/* alpha: Optional — 0 to 1 */
|
|
34
|
+
|
|
35
|
+
oklch(0.5 0.2 280) /* Medium purple-blue */
|
|
36
|
+
oklch(0.9 0.02 0) /* Near-white (very low chroma) */
|
|
37
|
+
oklch(0.4 0.25 160 / 0.8) /* Green with opacity */
|
|
38
|
+
oklch(0.5 0 0) /* Perfect neutral gray — C=0 means no hue */
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Key insight:** Unlike HSL where `hsl(0 0% 50%)` is medium gray, OKLCH gray is `oklch(0.5 0 0)` — and it's perceptually the same lightness regardless of hue. HSL's perceived lightness varies by hue (yellows look lighter than blues at the same HSL lightness).
|
|
42
|
+
|
|
43
|
+
### CSS OKLCH vs HSL comparison:
|
|
44
|
+
|
|
45
|
+
| Property | HSL | OKLCH |
|
|
46
|
+
|----------|-----|-------|
|
|
47
|
+
| Perceptual uniformity | No — lightness is hue-dependent | Yes — same L = same perceived lightness |
|
|
48
|
+
| Gray point | `hsl(0 0% X%)` — abstract | `oklch(L 0 H)` — C=0 at any hue = gray |
|
|
49
|
+
| Gamut | Always sRGB | Extends to P3, Rec.2020 |
|
|
50
|
+
| Browser support | Universal | 93%+ (Chrome 111+, Safari 15.4+, Firefox 113+) |
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 2. Threshold Values
|
|
55
|
+
|
|
56
|
+
| Range | Chroma (C) | Use |
|
|
57
|
+
|-------|-----------|-----|
|
|
58
|
+
| **Gray / Neutral** | `0` — `0.02` | Backgrounds, text, borders, dividers |
|
|
59
|
+
| **Subtle** | `0.02` — `0.08` | Tinted neutrals, subtle surfaces, muted text |
|
|
60
|
+
| **Accent** | `0.15` — `0.25` | Buttons, links, highlights, brand colors |
|
|
61
|
+
| **Vibrant** | `0.25` — `0.37` | Vibrant accents, marketing elements, illustrations |
|
|
62
|
+
|
|
63
|
+
**Lightness thresholds for text:**
|
|
64
|
+
|
|
65
|
+
| Use | L (Lightness) | Notes |
|
|
66
|
+
|-----|--------------|-------|
|
|
67
|
+
| Dark text on light bg | `0.15` — `0.4` | Below 0.15 is too close to black |
|
|
68
|
+
| Light text on dark bg | `0.7` — `0.95` | Above 0.95 is too close to white |
|
|
69
|
+
| Body text | `0.2` — `0.35` | Comfortable reading range |
|
|
70
|
+
| Muted/secondary | `0.4` — `0.6` | Lower contrast text |
|
|
71
|
+
| Surface backgrounds (light) | `0.95` — `1` | Near-white |
|
|
72
|
+
| Surface backgrounds (dark) | `0.1` — `0.2` | Near-black |
|
|
73
|
+
|
|
74
|
+
**Hue ranges for common colors:**
|
|
75
|
+
|
|
76
|
+
| Color | Hue (H) |
|
|
77
|
+
|-------|---------|
|
|
78
|
+
| Red | 20–40 |
|
|
79
|
+
| Orange | 50–75 |
|
|
80
|
+
| Yellow | 85–110 |
|
|
81
|
+
| Green | 130–160 |
|
|
82
|
+
| Teal | 170–200 |
|
|
83
|
+
| Blue | 220–270 |
|
|
84
|
+
| Purple | 280–310 |
|
|
85
|
+
| Pink | 320–350 |
|
|
86
|
+
| Neutral | 0 (any, C should be 0) |
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## 3. Conversion
|
|
91
|
+
|
|
92
|
+
### HEX/RGB → OKLCH
|
|
93
|
+
|
|
94
|
+
Use `color-mix()` in CSS or a conversion function:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
// JavaScript conversion using the CSS Color API
|
|
98
|
+
function hexToOklch(hex: string): { L: number; C: number; H: number } {
|
|
99
|
+
const canvas = new OffscreenCanvas(1, 1);
|
|
100
|
+
const ctx = canvas.getContext('2d')!;
|
|
101
|
+
ctx.fillStyle = hex;
|
|
102
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
103
|
+
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
|
|
104
|
+
|
|
105
|
+
// Convert sRGB linear → OKLab → OKLCH
|
|
106
|
+
// Use a library instead of manual math for production
|
|
107
|
+
return srgbToOklch([r / 255, g / 255, b / 255]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Recommended: use `culori` or `colorjs.io` library
|
|
111
|
+
import { rgb, oklch } from 'culori';
|
|
112
|
+
|
|
113
|
+
const color = oklch(rgb('#3B82F6'));
|
|
114
|
+
// → { L: 0.54, C: 0.18, H: 260, alpha: 1 }
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Online tools:**
|
|
118
|
+
- [oklch.com](https://oklch.com) — interactive OKLCH picker
|
|
119
|
+
- [huetone.art](https://huetone.art) — palette generation in OKLCH
|
|
120
|
+
- [colourcontrast.cc](https://colourcontrast.cc) — contrast checking with OKLCH
|
|
121
|
+
|
|
122
|
+
### OKLCH → HEX (with gamut mapping)
|
|
123
|
+
|
|
124
|
+
```css
|
|
125
|
+
/* Direct — browser handles gamut mapping automatically */
|
|
126
|
+
color: oklch(0.5 0.3 280);
|
|
127
|
+
|
|
128
|
+
/* Fallback for older browsers */
|
|
129
|
+
color: #6b5ae8;
|
|
130
|
+
color: oklch(0.5 0.3 280);
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { oklch, rgb } from 'culori';
|
|
135
|
+
|
|
136
|
+
// Convert OKLCH to sRGB hex (gamut mapped automatically)
|
|
137
|
+
const hex = rgb(oklch('oklch(0.5 0.3 280)'));
|
|
138
|
+
// Or with explicit gamut mapping to sRGB:
|
|
139
|
+
import { clampChroma, toGamut } from 'culori';
|
|
140
|
+
|
|
141
|
+
const gamutMapped = toGamut('srgb', 'oklch')(oklch('oklch(0.5 0.3 280)'));
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## 4. Palette Generation
|
|
147
|
+
|
|
148
|
+
### Single-hue palette (50–950 scale)
|
|
149
|
+
|
|
150
|
+
Given a hue and target lightnesses, vary L and C to create a consistent palette:
|
|
151
|
+
|
|
152
|
+
```css
|
|
153
|
+
/* Blue palette — single hue, varying lightness + chroma */
|
|
154
|
+
:root {
|
|
155
|
+
--blue-50: oklch(0.97 0.01 260);
|
|
156
|
+
--blue-100: oklch(0.93 0.03 260);
|
|
157
|
+
--blue-200: oklch(0.86 0.06 260);
|
|
158
|
+
--blue-300: oklch(0.78 0.10 260);
|
|
159
|
+
--blue-400: oklch(0.68 0.15 260);
|
|
160
|
+
--blue-500: oklch(0.56 0.18 260);
|
|
161
|
+
--blue-600: oklch(0.45 0.18 260);
|
|
162
|
+
--blue-700: oklch(0.35 0.15 260);
|
|
163
|
+
--blue-800: oklch(0.27 0.12 260);
|
|
164
|
+
--blue-900: oklch(0.20 0.08 260);
|
|
165
|
+
--blue-950: oklch(0.14 0.04 260);
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**Chroma curve for natural palettes:**
|
|
170
|
+
- 50: C=0.01 (near gray)
|
|
171
|
+
- Peak C at 500–600 (the most "colorful")
|
|
172
|
+
- 950: C=0.02–0.05 (near gray again)
|
|
173
|
+
|
|
174
|
+
### Semantic tokens from primitives
|
|
175
|
+
|
|
176
|
+
```css
|
|
177
|
+
:root {
|
|
178
|
+
/* Background */
|
|
179
|
+
--bg-primary: oklch(0.99 0 0);
|
|
180
|
+
--bg-secondary: oklch(0.96 0.02 260);
|
|
181
|
+
--bg-muted: oklch(0.94 0.01 260);
|
|
182
|
+
|
|
183
|
+
/* Foreground */
|
|
184
|
+
--fg-primary: oklch(0.15 0 0);
|
|
185
|
+
--fg-muted: oklch(0.45 0.01 260);
|
|
186
|
+
|
|
187
|
+
/* Brand */
|
|
188
|
+
--color-primary: oklch(0.56 0.18 260);
|
|
189
|
+
--color-accent: oklch(0.62 0.22 180);
|
|
190
|
+
|
|
191
|
+
/* Borders */
|
|
192
|
+
--border-default: oklch(0.88 0.01 260);
|
|
193
|
+
--border-muted: oklch(0.92 0.01 260);
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## 5. Contrast (APCA / WCAG 3)
|
|
200
|
+
|
|
201
|
+
OKLCH makes contrast calculation perceptually accurate. Use the lightness difference (`ΔL`) for quick estimates.
|
|
202
|
+
|
|
203
|
+
```css
|
|
204
|
+
/* Quick contrast heuristic in OKLCH: */
|
|
205
|
+
/* WCAG 2.1 AA (4.5:1) ≈ |L₁ - L₂| ≥ 0.35 */
|
|
206
|
+
/* WCAG 2.1 AA Large (3:1) ≈ |L₁ - L₂| ≥ 0.25 */
|
|
207
|
+
/* WCAG 2.1 AAA (7:1) ≈ |L₁ - L₂| ≥ 0.50 */
|
|
208
|
+
|
|
209
|
+
/* Example: white text on blue button */
|
|
210
|
+
/* White: L=1, Blue-500: L=0.56, ΔL=0.44 → ≥ 0.35 ✓ AA pass */
|
|
211
|
+
button {
|
|
212
|
+
background: oklch(0.56 0.18 260); /* Blue-500 */
|
|
213
|
+
color: oklch(1 0 0); /* White */
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/* Example: gray text on white background — FAIL */
|
|
217
|
+
/* Gray-400: L=0.63, White: L=1, ΔL=0.37 → barely passes AA */
|
|
218
|
+
.text-muted {
|
|
219
|
+
color: oklch(0.63 0.01 260); /* Gray-400 */
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* Better: */
|
|
223
|
+
.text-muted {
|
|
224
|
+
color: oklch(0.45 0.01 260); /* Gray-600 — ΔL=0.55 ✓ */
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
**Contrast check recipe:**
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
function contrastRatio(L1: number, L2: number): number {
|
|
232
|
+
// OKLCH-based APCA-like contrast
|
|
233
|
+
const lighter = Math.max(L1, L2);
|
|
234
|
+
const darker = Math.min(L1, L2);
|
|
235
|
+
const contrast = (lighter + 0.1) / (darker + 0.1);
|
|
236
|
+
return contrast;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Use with culori for precise WCAG 3 APCA:
|
|
240
|
+
import { contrastAPCA } from 'culori';
|
|
241
|
+
|
|
242
|
+
const ratio = contrastAPCA('oklch(0.15 0 0)', 'oklch(0.97 0 0)');
|
|
243
|
+
// APCA values: 0-100+, 45+ = preferred for body text
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## 6. Gamut Mapping
|
|
249
|
+
|
|
250
|
+
OKLCH colors can fall outside the sRGB gamut. Browsers automatically clip them, but you can control the mapping:
|
|
251
|
+
|
|
252
|
+
```css
|
|
253
|
+
/* This color is outside sRGB — vivid saturated blue-purple */
|
|
254
|
+
.vibrant-element {
|
|
255
|
+
color: oklch(0.5 0.37 290);
|
|
256
|
+
/* Browser clips to nearest sRGB color automatically */
|
|
257
|
+
/* Result will be approximately #8855ff */
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
**Gamut mapping strategies:**
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
import { toGamut, clampChroma, oklch } from 'culori';
|
|
265
|
+
|
|
266
|
+
const outOfGamut = oklch('oklch(0.5 0.37 290)');
|
|
267
|
+
|
|
268
|
+
// Strategy 1: Clip chroma until color fits sRGB (preserves hue + lightness)
|
|
269
|
+
const strategy1 = toGamut('srgb', 'oklch', 'clip')(outOfGamut);
|
|
270
|
+
|
|
271
|
+
// Strategy 2: Gamut map with CSS-compatible algorithm
|
|
272
|
+
const strategy2 = toGamut('srgb', 'oklch')(outOfGamut);
|
|
273
|
+
|
|
274
|
+
// Strategy 3: Manual chroma reduction
|
|
275
|
+
const manual = { ...outOfGamut, c: 0.25 }; // Reduce chroma until in gamut
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
**Detection:**
|
|
279
|
+
|
|
280
|
+
```css
|
|
281
|
+
/* Use @supports to detect OKLCH support and provide fallback */
|
|
282
|
+
.hero {
|
|
283
|
+
background: #6b5ae8; /* sRGB fallback */
|
|
284
|
+
background: oklch(0.5 0.3 280); /* OKLCH — wider gamut */
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/* Also detect display gamut */
|
|
288
|
+
@media (color-gamut: p3) {
|
|
289
|
+
.hero {
|
|
290
|
+
background: oklch(0.5 0.3 280);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
## 7. Tailwind v4 Migration
|
|
298
|
+
|
|
299
|
+
### Tailwind v4 `@theme` with OKLCH
|
|
300
|
+
|
|
301
|
+
```css
|
|
302
|
+
/* app.css — Tailwind v4 theme with OKLCH */
|
|
303
|
+
@import "tailwindcss";
|
|
304
|
+
|
|
305
|
+
@theme {
|
|
306
|
+
--color-primary-50: oklch(0.97 0.01 260);
|
|
307
|
+
--color-primary-100: oklch(0.93 0.03 260);
|
|
308
|
+
--color-primary-200: oklch(0.86 0.06 260);
|
|
309
|
+
--color-primary-300: oklch(0.78 0.10 260);
|
|
310
|
+
--color-primary-400: oklch(0.68 0.15 260);
|
|
311
|
+
--color-primary-500: oklch(0.56 0.18 260);
|
|
312
|
+
--color-primary-600: oklch(0.45 0.18 260);
|
|
313
|
+
--color-primary-700: oklch(0.35 0.15 260);
|
|
314
|
+
--color-primary-800: oklch(0.27 0.12 260);
|
|
315
|
+
--color-primary-900: oklch(0.20 0.08 260);
|
|
316
|
+
--color-primary-950: oklch(0.14 0.04 260);
|
|
317
|
+
|
|
318
|
+
--color-surface: oklch(0.99 0 0);
|
|
319
|
+
--color-surface-alt: oklch(0.96 0.02 260);
|
|
320
|
+
--color-border: oklch(0.88 0.01 260);
|
|
321
|
+
--color-muted: oklch(0.45 0.01 260);
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
### Dark mode with OKLCH
|
|
326
|
+
|
|
327
|
+
OKLCH makes dark mode trivial — just adjust L (lightness) while keeping H and C the same:
|
|
328
|
+
|
|
329
|
+
```css
|
|
330
|
+
@custom-variant dark (&:where(.dark, .dark *));
|
|
331
|
+
|
|
332
|
+
@theme {
|
|
333
|
+
/* Light — high L for surfaces */
|
|
334
|
+
--color-surface: oklch(0.99 0 0);
|
|
335
|
+
--color-surface-alt: oklch(0.96 0.02 260);
|
|
336
|
+
|
|
337
|
+
/* Dark — low L for surfaces */
|
|
338
|
+
--color-surface-dark: oklch(0.12 0 0);
|
|
339
|
+
--color-surface-alt-dark: oklch(0.15 0.02 260);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/* Or use CSS variables with media query: */
|
|
343
|
+
:root {
|
|
344
|
+
--bg: oklch(0.99 0 0);
|
|
345
|
+
--text: oklch(0.15 0 0);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
@media (prefers-color-scheme: dark) {
|
|
349
|
+
:root {
|
|
350
|
+
--bg: oklch(0.12 0 0);
|
|
351
|
+
--text: oklch(0.88 0 0);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/* The brand color stays the same — only background/text flip */
|
|
356
|
+
/* primary-500 remains oklch(0.56 0.18 260) in both modes */
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### Tinted neutrals
|
|
360
|
+
|
|
361
|
+
Give your neutral tones a subtle brand hue by adding very low chroma:
|
|
362
|
+
|
|
363
|
+
```css
|
|
364
|
+
/* BEFORE — true neutral grays (bland) */
|
|
365
|
+
--gray-100: oklch(0.96 0 0);
|
|
366
|
+
--gray-500: oklch(0.55 0 0);
|
|
367
|
+
|
|
368
|
+
/* AFTER — tinted with brand hue (sophisticated) */
|
|
369
|
+
/* C=0.01 is imperceptible as "color" but reads as a richer neutral */
|
|
370
|
+
--gray-100: oklch(0.96 0.01 260); /* Barely blue-tinted */
|
|
371
|
+
--gray-500: oklch(0.55 0.02 260); /* Subtle warmth from brand hue */
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### Migration from HEX/RGB
|
|
375
|
+
|
|
376
|
+
```css
|
|
377
|
+
/* BEFORE — Tailwind v3, HEX colors */
|
|
378
|
+
:root {
|
|
379
|
+
--color-primary: #3B82F6;
|
|
380
|
+
--color-surface: #FFFFFF;
|
|
381
|
+
--color-muted: #6B7280;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/* AFTER — Tailwind v4, OKLCH */
|
|
385
|
+
:root {
|
|
386
|
+
--color-primary: oklch(0.56 0.18 260);
|
|
387
|
+
--color-surface: oklch(1 0 0);
|
|
388
|
+
--color-muted: oklch(0.45 0.01 260);
|
|
389
|
+
}
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
**Migration checklist:**
|
|
393
|
+
1. Convert all HEX colors to OKLCH using a converter tool
|
|
394
|
+
2. Update Tailwind v4 `@theme` with OKLCH values
|
|
395
|
+
3. Keep HEX fallbacks for older browsers if needed
|
|
396
|
+
4. Adjust chroma values — high-chroma HEX colors may need C reduction
|
|
397
|
+
5. Verify contrast ratios — OKLCH reveals contrast issues that HEX masked
|
|
398
|
+
6. Test dark mode — OKLCH's uniform L makes this trivial
|
|
399
|
+
|
|
400
|
+
---
|
|
401
|
+
|
|
402
|
+
## Don't
|
|
403
|
+
|
|
404
|
+
| Pattern | Replacement | Because |
|
|
405
|
+
|---------|-------------|---------|
|
|
406
|
+
| C too high for text | Keep text C ≤ 0.15; reserve C≥0.2 for display/brand use | High chroma text is fatiguing at small sizes |
|
|
407
|
+
| Not checking gamut | Reduce C or use `toGamut()` for predictable clipping | Colors outside sRGB clip unpredictably |
|
|
408
|
+
| Mixing color spaces in palette | Use one color space for the entire palette | Mixed spaces make comparison impossible |
|
|
409
|
+
| C=0 for all grays | Use C=0.01 with brand hue for richer neutrals | True neutral grays can look sterile |
|
|
410
|
+
| No fallback for OKLCH | Use `color: #hex; color: oklch(...)` pattern | OKLCH unsupported in older browsers |
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
## Verification
|
|
415
|
+
|
|
416
|
+
- [ ] All colors use OKLCH syntax (`oklch(L C H / alpha)`)
|
|
417
|
+
- [ ] Grays and neutrals use C=0 to C=0.02 range
|
|
418
|
+
- [ ] Accent colors use C=0.15 to C=0.25 range
|
|
419
|
+
- [ ] Text colors do not exceed C=0.15 (readability)
|
|
420
|
+
- [ ] Contrast ratio checked: |ΔL| ≥ 0.35 for body text, ≥ 0.25 for large text
|
|
421
|
+
- [ ] Gamut checked: no colors outside sRGB without fallback
|
|
422
|
+
- [ ] HEX/RGB fallback provided for colors where gamut mapping matters
|
|
423
|
+
- [ ] Palette follows natural chroma curve (peak at 500/600, low at 50/950)
|
|
424
|
+
- [ ] Neutrals are tinted with brand hue (C ≥ 0.01) for richness
|
|
425
|
+
- [ ] Tailwind v4 `@theme` uses OKLCH values
|
|
426
|
+
- [ ] Dark mode flips L values while keeping H and C consistent
|