@minhduydev/mdpi 0.3.2 → 0.4.1
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/README.md +2 -1
- package/dist/index.js +202 -4
- package/dist/template/.pi/README.md +29 -8
- package/dist/template/.pi/VERSION +1 -1
- package/dist/template/.pi/agents/general.md +1 -1
- package/dist/template/.pi/agents/review.md +1 -1
- package/dist/template/.pi/extensions/templates-injector.ts +1 -1
- package/dist/template/.pi/packages.json +13 -0
- package/dist/template/.pi/prompts/audit.md +4 -1
- package/dist/template/.pi/prompts/create.md +1 -0
- package/dist/template/.pi/prompts/fix.md +7 -1
- package/dist/template/.pi/prompts/gc.md +3 -0
- package/dist/template/.pi/prompts/init.md +3 -0
- package/dist/template/.pi/prompts/plan.md +5 -2
- package/dist/template/.pi/prompts/research.md +3 -0
- package/dist/template/.pi/prompts/ship.md +7 -1
- package/dist/template/.pi/prompts/verify.md +4 -1
- package/dist/template/.pi/skills/INDEX.md +49 -12
- 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 +106 -0
- 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 +59 -46
- package/dist/template/.pi/skills/frontend-ui-engineering/SKILL.md +21 -27
- package/dist/template/.pi/skills/memory-system/SKILL.md +118 -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/ui-craft-principles/SKILL.md +564 -0
- package/dist/template/.pi/skills/ui-quality-audit/SKILL.md +329 -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 +3 -1
- package/package.json +1 -1
- /package/dist/template/.pi/templates/{design.md → feature-design.md} +0 -0
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ui-craft-principles
|
|
3
|
+
description: 16 craft principles for UI polish — concentric border-radius, optical alignment, interruptible animations, tabular numbers, and more
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# UI Craft Principles
|
|
7
|
+
|
|
8
|
+
## When to Use
|
|
9
|
+
|
|
10
|
+
- When polishing UI after the basic implementation is working
|
|
11
|
+
- During code review to catch craft-level issues
|
|
12
|
+
- When building design-system components that need production-grade polish
|
|
13
|
+
- When the UI looks "good enough" but not "pixel-perfect"
|
|
14
|
+
|
|
15
|
+
## When NOT to Use
|
|
16
|
+
|
|
17
|
+
- Quick prototypes where craft polish is premature
|
|
18
|
+
- Non-visual backend work (APIs, data processing, CLI tools)
|
|
19
|
+
- When the layout is still being iterated on (apply after layout is stable)
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## The 16 Craft Principles
|
|
24
|
+
|
|
25
|
+
### 1. Concentric Border-Radius
|
|
26
|
+
|
|
27
|
+
**Rule:** When nesting a container inside another, the inner radius should be `outer radius - padding`. This creates concentric curves.
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
// BEFORE — both have same radius, creates "fattened" corners on inner element
|
|
31
|
+
<div className="rounded-xl p-4 bg-zinc-100">
|
|
32
|
+
<div className="rounded-xl bg-white">...</div>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
// AFTER — inner radius = outer radius - padding (16px - 16px = 0, or use rounded-md)
|
|
36
|
+
<div className="rounded-xl p-4 bg-zinc-100">
|
|
37
|
+
<div className="rounded-lg bg-white">...</div>
|
|
38
|
+
{/* ^^^ rounded-xl(12px) - p-4(16px) = too small, use rounded-lg(8px) */}
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
// Better formula: rounded-lg(8px) p-4 => inner uses rounded-md(6px) or rounded(4px)
|
|
42
|
+
<div className="rounded-lg p-4 bg-zinc-100">
|
|
43
|
+
<div className="rounded-md bg-white">...</div>
|
|
44
|
+
</div>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Formula:** `innerRadius = outerRadius - padding`. If result is negative, use 0. Minimum inner radius is 0 (sharp corners).
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
### 2. Optical Alignment
|
|
52
|
+
|
|
53
|
+
**Rule:** Text always needs a slight left offset to appear visually centered. Font glyphs have inherent left-side bearing that makes them look off-center.
|
|
54
|
+
|
|
55
|
+
```css
|
|
56
|
+
/* BEFORE */
|
|
57
|
+
.headline {
|
|
58
|
+
margin-left: 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* AFTER — compensate for glyph bearing */
|
|
62
|
+
.headline {
|
|
63
|
+
margin-left: -0.05em;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* For icons next to text: */
|
|
67
|
+
.icon-label {
|
|
68
|
+
display: inline-flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
gap: 0.375em; /* not px — use em so it scales with font */
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.icon-label svg {
|
|
74
|
+
/* Icons appear smaller than text at same size — bump up slightly */
|
|
75
|
+
width: 1.1em;
|
|
76
|
+
height: 1.1em;
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
### 3. Multi-Shadow Layering
|
|
83
|
+
|
|
84
|
+
**Rule:** Use 2-3 shadows to create realistic depth instead of a single heavy box-shadow.
|
|
85
|
+
|
|
86
|
+
```css
|
|
87
|
+
/* BEFORE — single heavy shadow looks cheap */
|
|
88
|
+
.card {
|
|
89
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* AFTER — layered shadows for realistic depth */
|
|
93
|
+
.card {
|
|
94
|
+
box-shadow:
|
|
95
|
+
0 1px 2px rgba(0,0,0,0.04), /* contact shadow */
|
|
96
|
+
0 4px 8px rgba(0,0,0,0.06), /* mid elevation */
|
|
97
|
+
0 12px 24px rgba(0,0,0,0.08); /* far falloff */
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* Tinted shadows — match the background hue */
|
|
101
|
+
.card-dark {
|
|
102
|
+
box-shadow:
|
|
103
|
+
0 1px 2px rgba(0,0,0,0.2),
|
|
104
|
+
0 4px 8px rgba(0,0,0,0.25),
|
|
105
|
+
0 12px 24px rgba(0,0,0,0.3);
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
### 4. Interruptible Animations
|
|
112
|
+
|
|
113
|
+
**Rule:** Animations should use spring-based physics (interruptible, natural-feeling) not duration-based linear easing.
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
// BEFORE — fixed duration, cannot be interrupted
|
|
117
|
+
const animation = useSpring({
|
|
118
|
+
opacity: isOpen ? 1 : 0,
|
|
119
|
+
config: { duration: 300 },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// AFTER — spring-based, interruptible
|
|
123
|
+
import { useSpring, animated } from '@react-spring/web';
|
|
124
|
+
|
|
125
|
+
const animation = useSpring({
|
|
126
|
+
opacity: isOpen ? 1 : 0,
|
|
127
|
+
config: { mass: 1, tension: 280, friction: 24 },
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
```css
|
|
132
|
+
/* If using CSS transitions instead of spring: */
|
|
133
|
+
.element {
|
|
134
|
+
/* duration-only timing feels robotic */
|
|
135
|
+
transition: transform 300ms ease, opacity 200ms ease;
|
|
136
|
+
/* Better — still CSS but with natural easing */
|
|
137
|
+
transition: transform 250ms cubic-bezier(0.22, 1, 0.36, 1),
|
|
138
|
+
opacity 200ms ease;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* The "ease-out-forward" curve: cubic-bezier(0.22, 1, 0.36, 1) */
|
|
142
|
+
/* Fast start, gentle end — mimics spring behavior */
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
### 5. Scale on Press
|
|
148
|
+
|
|
149
|
+
**Rule:** Pressable elements should scale down slightly on `:active` to simulate physical depression.
|
|
150
|
+
|
|
151
|
+
```tsx
|
|
152
|
+
// BEFORE — no tactile feedback
|
|
153
|
+
<button className="bg-primary text-white rounded-md px-4 py-2">
|
|
154
|
+
Click me
|
|
155
|
+
</button>
|
|
156
|
+
|
|
157
|
+
// AFTER — scale on press + hover elevation
|
|
158
|
+
<button className="
|
|
159
|
+
bg-primary text-white rounded-md px-4 py-2
|
|
160
|
+
transition-transform duration-100 ease-out
|
|
161
|
+
hover:scale-[1.02]
|
|
162
|
+
active:scale-[0.98]
|
|
163
|
+
">
|
|
164
|
+
Click me
|
|
165
|
+
</button>
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
### 6. Tabular Numbers for Data
|
|
171
|
+
|
|
172
|
+
**Rule:** Numbers in tables, prices, stats, and data displays must use tabular figures (fixed-width numerals) to prevent visual jitter.
|
|
173
|
+
|
|
174
|
+
```css
|
|
175
|
+
/* BEFORE — proportional numbers jitter as values change */
|
|
176
|
+
.price {
|
|
177
|
+
font-size: 1.5rem;
|
|
178
|
+
font-weight: 600;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* AFTER — tabular numbers stay aligned */
|
|
182
|
+
.price {
|
|
183
|
+
font-variant-numeric: tabular-nums;
|
|
184
|
+
/* Also good for data displays: */
|
|
185
|
+
font-variant-numeric: tabular-nums lining-nums;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* For monospace data, typical combo: */
|
|
189
|
+
.data-value {
|
|
190
|
+
font-variant-numeric: tabular-nums slashed-zero;
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
### 7. `will-change` Sparingly
|
|
197
|
+
|
|
198
|
+
**Rule:** Only use `will-change` immediately before an animation starts, and remove it after. Never leave it on permanently — it wastes GPU memory.
|
|
199
|
+
|
|
200
|
+
```tsx
|
|
201
|
+
// BEFORE — will-change permanently applied
|
|
202
|
+
<div className="will-change-transform" />
|
|
203
|
+
|
|
204
|
+
// AFTER — apply before animation, clean up after
|
|
205
|
+
const [animating, setAnimating] = useState(false);
|
|
206
|
+
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (animating) {
|
|
209
|
+
const timer = setTimeout(() => setAnimating(false), 300);
|
|
210
|
+
return () => clearTimeout(timer);
|
|
211
|
+
}
|
|
212
|
+
}, [animating]);
|
|
213
|
+
|
|
214
|
+
// In component:
|
|
215
|
+
<div
|
|
216
|
+
className={animating ? 'will-change-transform' : ''}
|
|
217
|
+
style={{ transition: 'transform 300ms ease' }}
|
|
218
|
+
/>
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
### 8. Hit Areas ≥ 44px
|
|
224
|
+
|
|
225
|
+
**Rule:** Every interactive element must have a minimum 44×44px hit area — even if the visual element is smaller.
|
|
226
|
+
|
|
227
|
+
```tsx
|
|
228
|
+
// BEFORE — icon-only button, 24×24 visual, tiny hit area
|
|
229
|
+
<button className="p-0" aria-label="Close">
|
|
230
|
+
<XIcon className="h-6 w-6" />
|
|
231
|
+
</button>
|
|
232
|
+
|
|
233
|
+
// AFTER — padded to 44px minimum
|
|
234
|
+
<button
|
|
235
|
+
className="p-2.5"
|
|
236
|
+
style={{ minWidth: '44px', minHeight: '44px' }}
|
|
237
|
+
aria-label="Close"
|
|
238
|
+
>
|
|
239
|
+
<XIcon className="h-6 w-6" aria-hidden="true" />
|
|
240
|
+
</button>
|
|
241
|
+
|
|
242
|
+
// For inline links — use larger padding on the link itself
|
|
243
|
+
<a href="/docs" className="inline-block py-2 px-1 -mx-1">
|
|
244
|
+
Documentation
|
|
245
|
+
</a>
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
### 9. Skeleton Specificity
|
|
251
|
+
|
|
252
|
+
**Rule:** Skeleton loaders must match the exact layout of the real content — not generic bars.
|
|
253
|
+
|
|
254
|
+
```tsx
|
|
255
|
+
// BEFORE — generic skeleton, doesn't match layout
|
|
256
|
+
{loading && (
|
|
257
|
+
<div className="space-y-3">
|
|
258
|
+
<div className="h-4 rounded bg-zinc-200 animate-pulse" />
|
|
259
|
+
<div className="h-4 rounded bg-zinc-200 animate-pulse" />
|
|
260
|
+
<div className="h-4 rounded bg-zinc-200 animate-pulse" />
|
|
261
|
+
</div>
|
|
262
|
+
)}
|
|
263
|
+
{!loading && (
|
|
264
|
+
<div className="flex gap-4">
|
|
265
|
+
<img className="w-16 h-16 rounded-full" />
|
|
266
|
+
<div>
|
|
267
|
+
<h3 className="text-lg font-semibold">Name</h3>
|
|
268
|
+
<p className="text-sm text-gray-500">Description</p>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
)}
|
|
272
|
+
|
|
273
|
+
// AFTER — skeleton matches exact real layout
|
|
274
|
+
{loading ? (
|
|
275
|
+
<div className="flex gap-4">
|
|
276
|
+
<div className="w-16 h-16 rounded-full bg-zinc-200 animate-pulse shrink-0" />
|
|
277
|
+
<div className="space-y-2 flex-1">
|
|
278
|
+
<div className="h-5 w-32 rounded bg-zinc-200 animate-pulse" />
|
|
279
|
+
<div className="h-4 w-48 rounded bg-zinc-200 animate-pulse" />
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
) : (
|
|
283
|
+
<div className="flex gap-4">
|
|
284
|
+
<img className="w-16 h-16 rounded-full" />
|
|
285
|
+
<div>
|
|
286
|
+
<h3 className="text-lg font-semibold">Name</h3>
|
|
287
|
+
<p className="text-sm text-gray-500">Description</p>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
)}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
### 10. Text Balance
|
|
296
|
+
|
|
297
|
+
**Rule:** Use `text-wrap: balance` on headlines to prevent uneven line breaks (one word on the last line).
|
|
298
|
+
|
|
299
|
+
```css
|
|
300
|
+
/* BEFORE — "rag" with one word on last line */
|
|
301
|
+
.headline {
|
|
302
|
+
font-size: 2.5rem;
|
|
303
|
+
line-height: 1.1;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* AFTER — balanced wrapping */
|
|
307
|
+
.headline {
|
|
308
|
+
font-size: 2.5rem;
|
|
309
|
+
line-height: 1.1;
|
|
310
|
+
text-wrap: balance;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/* For short content, also consider pretty for justified: */
|
|
314
|
+
.hero-tagline {
|
|
315
|
+
text-wrap: pretty;
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
### 11. Scroll Margin for Anchor Links
|
|
322
|
+
|
|
323
|
+
**Rule:** Anchor links should have scroll margin so the target isn't hidden behind fixed headers.
|
|
324
|
+
|
|
325
|
+
```css
|
|
326
|
+
/* BEFORE — anchor links scroll target to top of viewport */
|
|
327
|
+
#section-target {
|
|
328
|
+
/* target is hidden under fixed header */
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/* AFTER — reserve space above the target */
|
|
332
|
+
#section-target,
|
|
333
|
+
[id] {
|
|
334
|
+
scroll-margin-top: 6rem; /* match fixed header height */
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/* In Tailwind: */
|
|
338
|
+
/* <div id="section" className="scroll-mt-24"> */
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
```tsx
|
|
342
|
+
// For smooth scrolling:
|
|
343
|
+
<nav>
|
|
344
|
+
<a href="#pricing" className="scroll-smooth">
|
|
345
|
+
Pricing
|
|
346
|
+
</a>
|
|
347
|
+
</nav>
|
|
348
|
+
|
|
349
|
+
// Enable on the document:
|
|
350
|
+
// html { scroll-behavior: smooth; }
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
### 12. Focus Ring Offset
|
|
356
|
+
|
|
357
|
+
**Rule:** When using `outline` for focus rings, always add `outline-offset` so the ring doesn't get clipped by borders or shadows.
|
|
358
|
+
|
|
359
|
+
```css
|
|
360
|
+
/* BEFORE — outline clips on bordered elements */
|
|
361
|
+
*:focus-visible {
|
|
362
|
+
outline: 2px solid var(--color-primary);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/* AFTER — offset keeps the ring visible */
|
|
366
|
+
*:focus-visible {
|
|
367
|
+
outline: 2px solid var(--color-primary);
|
|
368
|
+
outline-offset: 2px;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/* For buttons with borders: */
|
|
372
|
+
button:focus-visible {
|
|
373
|
+
outline: 2px solid var(--color-primary);
|
|
374
|
+
outline-offset: 2px;
|
|
375
|
+
/* ring sits outside the border, fully visible */
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
---
|
|
380
|
+
|
|
381
|
+
### 13. Animation Duration Hierarchy
|
|
382
|
+
|
|
383
|
+
**Rule:** Maintain a strict duration scale so animations feel coherent.
|
|
384
|
+
|
|
385
|
+
```
|
|
386
|
+
100ms — micro-interactions (hover, press, color change)
|
|
387
|
+
200ms — small transitions (opacity, small transforms)
|
|
388
|
+
300ms — medium transitions (panel slide, fade-in)
|
|
389
|
+
500ms — large transitions (page transitions, modals)
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
```css
|
|
393
|
+
:root {
|
|
394
|
+
--duration-micro: 100ms;
|
|
395
|
+
--duration-small: 200ms;
|
|
396
|
+
--duration-medium: 300ms;
|
|
397
|
+
--duration-large: 500ms;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
.button {
|
|
401
|
+
transition: background-color var(--duration-micro) ease,
|
|
402
|
+
transform var(--duration-small) ease;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
.panel {
|
|
406
|
+
transition: transform var(--duration-medium) ease,
|
|
407
|
+
opacity var(--duration-medium) ease;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
.modal-backdrop {
|
|
411
|
+
transition: opacity var(--duration-medium) ease;
|
|
412
|
+
}
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
---
|
|
416
|
+
|
|
417
|
+
### 14. `prefers-reduced-motion` (Mandatory)
|
|
418
|
+
|
|
419
|
+
**Rule:** Every animation must respect the user's motion preference. No exceptions.
|
|
420
|
+
|
|
421
|
+
```css
|
|
422
|
+
/* BEFORE — animation plays regardless */
|
|
423
|
+
.fade-in {
|
|
424
|
+
animation: fadeIn 300ms ease-out;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/* AFTER — disabled when user prefers reduced motion */
|
|
428
|
+
.fade-in {
|
|
429
|
+
animation: fadeIn 300ms ease-out;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
@media (prefers-reduced-motion: reduce) {
|
|
433
|
+
*, *::before, *::after {
|
|
434
|
+
animation-duration: 0.01ms !important;
|
|
435
|
+
animation-iteration-count: 1 !important;
|
|
436
|
+
transition-duration: 0.01ms !important;
|
|
437
|
+
scroll-behavior: auto !important;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
```tsx
|
|
443
|
+
// In JavaScript/React:
|
|
444
|
+
import { useReducedMotion } from 'framer-motion'; // or:
|
|
445
|
+
|
|
446
|
+
function usePrefersReducedMotion() {
|
|
447
|
+
const [prefersReduced, setPrefersReduced] = useState(false);
|
|
448
|
+
|
|
449
|
+
useEffect(() => {
|
|
450
|
+
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
451
|
+
setPrefersReduced(mq.matches);
|
|
452
|
+
const handler = (e) => setPrefersReduced(e.matches);
|
|
453
|
+
mq.addEventListener('change', handler);
|
|
454
|
+
return () => mq.removeEventListener('change', handler);
|
|
455
|
+
}, []);
|
|
456
|
+
|
|
457
|
+
return prefersReduced;
|
|
458
|
+
}
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
---
|
|
462
|
+
|
|
463
|
+
### 15. Image Aspect-Ratio Reservation
|
|
464
|
+
|
|
465
|
+
**Rule:** Reserve space for images before they load to prevent layout shift.
|
|
466
|
+
|
|
467
|
+
```tsx
|
|
468
|
+
// BEFORE — image causes layout shift on load
|
|
469
|
+
<div className="grid grid-cols-3 gap-4">
|
|
470
|
+
{images.map(src => <img key={src} src={src} />)}
|
|
471
|
+
</div>
|
|
472
|
+
|
|
473
|
+
// AFTER — aspect ratio reserved, no layout shift
|
|
474
|
+
<div className="grid grid-cols-3 gap-4">
|
|
475
|
+
{images.map(src => (
|
|
476
|
+
<div className="aspect-[4/3] relative overflow-hidden bg-zinc-100">
|
|
477
|
+
<img
|
|
478
|
+
src={src}
|
|
479
|
+
alt=""
|
|
480
|
+
className="absolute inset-0 w-full h-full object-cover"
|
|
481
|
+
loading="lazy"
|
|
482
|
+
/>
|
|
483
|
+
</div>
|
|
484
|
+
))}
|
|
485
|
+
</div>
|
|
486
|
+
|
|
487
|
+
// Alternative: using aspect-ratio directly on img
|
|
488
|
+
<img
|
|
489
|
+
src={src}
|
|
490
|
+
alt=""
|
|
491
|
+
className="w-full aspect-video object-cover"
|
|
492
|
+
loading="lazy"
|
|
493
|
+
/>
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
---
|
|
497
|
+
|
|
498
|
+
### 16. Color Gamut Awareness
|
|
499
|
+
|
|
500
|
+
**Rule:** Use OKLCH for colors that extend beyond sRGB, and provide sRGB fallbacks for older browsers.
|
|
501
|
+
|
|
502
|
+
```css
|
|
503
|
+
/* BEFORE — sRGB only, losing gamut range */
|
|
504
|
+
.hero {
|
|
505
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/* AFTER — OKLCH for wider gamut, sRGB fallback */
|
|
509
|
+
.hero {
|
|
510
|
+
/* Fallback for older browsers */
|
|
511
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
512
|
+
/* OKLCH for wider gamut displays */
|
|
513
|
+
background: linear-gradient(
|
|
514
|
+
135deg,
|
|
515
|
+
oklch(0.6 0.2 280) 0%,
|
|
516
|
+
oklch(0.5 0.25 310) 100%
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/* Detection via @supports */
|
|
521
|
+
@supports (color: oklch(0 0 0)) {
|
|
522
|
+
.hero {
|
|
523
|
+
background: linear-gradient(135deg,
|
|
524
|
+
oklch(0.6 0.2 280) 0%,
|
|
525
|
+
oklch(0.5 0.25 310) 100%
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
---
|
|
532
|
+
|
|
533
|
+
## Don't
|
|
534
|
+
|
|
535
|
+
| Pattern | Replacement | Because |
|
|
536
|
+
|---------|-------------|---------|
|
|
537
|
+
| Same border-radius on nested containers | Apply concentric formula: `innerRadius = outerRadius - padding` | Same radius creates "fattened" corner look on inner element |
|
|
538
|
+
| Single heavy `box-shadow` | 2-3 layered shadows with decreasing opacity | Layered shadows create realistic depth |
|
|
539
|
+
| Fixed-duration CSS transitions | Spring-based physics or natural easing curves | Fixed duration can't be interrupted mid-animation |
|
|
540
|
+
| No `:active` state on pressable elements | `active:scale-[0.98]` for physical push feedback | Missing tactile feedback makes UI feel flat |
|
|
541
|
+
| Proportional numbers in data displays | `font-variant-numeric: tabular-nums` | Proportional numbers jitter as values change |
|
|
542
|
+
| Permanent `will-change` on animated elements | Apply before animation, remove after | Permanent will-change wastes GPU memory |
|
|
543
|
+
| Generic skeleton bars not matching layout | Skeleton matching exact real content layout | Generic skeletons don't provide useful placeholder |
|
|
544
|
+
| `outline: none` without `:focus-visible` fallback | `outline: 2px solid` with `outline-offset: 2px` using `:focus-visible` | Removing outlines breaks keyboard navigation |
|
|
545
|
+
| No `prefers-reduced-motion` handling | Always respect user motion preferences with media query | Animations can cause discomfort for vestibular disorders |
|
|
546
|
+
|
|
547
|
+
## Verification
|
|
548
|
+
|
|
549
|
+
- [ ] Concentric radius formula applied to all nested containers
|
|
550
|
+
- [ ] Text elements have optical alignment compensation (`margin-left: -0.05em`)
|
|
551
|
+
- [ ] All shadows use 2-3 layer multi-shadow syntax (not single box-shadow)
|
|
552
|
+
- [ ] Animations use spring/physics-based parameters, not just duration
|
|
553
|
+
- [ ] Pressable elements have `active:scale-[0.98]` or equivalent
|
|
554
|
+
- [ ] Numeric data displays use `font-variant-numeric: tabular-nums`
|
|
555
|
+
- [ ] No permanent `will-change` properties in CSS/JSX
|
|
556
|
+
- [ ] All interactive elements have ≥ 44×44px hit area
|
|
557
|
+
- [ ] Skeleton loaders match final layout structure (not generic bars)
|
|
558
|
+
- [ ] Headlines use `text-wrap: balance`
|
|
559
|
+
- [ ] Anchor-linked elements have `scroll-margin-top`
|
|
560
|
+
- [ ] Focus rings have `outline-offset: 2px`
|
|
561
|
+
- [ ] Animation durations follow the 100/200/300/500ms scale
|
|
562
|
+
- [ ] `prefers-reduced-motion` media query present for all animations
|
|
563
|
+
- [ ] Images have reserved aspect-ratio containers to prevent layout shift
|
|
564
|
+
- [ ] OKLCH colors used with sRGB fallback where gamut extension matters
|