@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.
Files changed (48) hide show
  1. package/dist/index.js +1 -1
  2. package/dist/template/.pi/VERSION +1 -1
  3. package/dist/template/.pi/extensions/templates-injector.ts +34 -6
  4. package/dist/template/.pi/prompts/INDEX.md +3 -9
  5. package/dist/template/.pi/skills/INDEX.md +81 -19
  6. package/dist/template/.pi/skills/accessibility-audit/SKILL.md +8 -2
  7. package/dist/template/.pi/skills/baseline-ui/SKILL.md +211 -0
  8. package/dist/template/.pi/skills/dcp-hygiene/SKILL.md +1 -1
  9. package/dist/template/.pi/skills/design-taste-frontend/SKILL.md +53 -42
  10. package/dist/template/.pi/skills/fixing-accessibility/SKILL.md +509 -0
  11. package/dist/template/.pi/skills/frontend-design/SKILL.md +60 -47
  12. package/dist/template/.pi/skills/frontend-design/references/animation/motion-advanced.md +88 -15
  13. package/dist/template/.pi/skills/frontend-design/references/animation/motion-core.md +148 -13
  14. package/dist/template/.pi/skills/frontend-design/references/shadcn/setup.md +127 -20
  15. package/dist/template/.pi/skills/frontend-ui-engineering/SKILL.md +21 -27
  16. package/dist/template/.pi/skills/nextjs-app-router/SKILL.md +334 -0
  17. package/dist/template/.pi/skills/nextjs-cache/SKILL.md +262 -0
  18. package/dist/template/.pi/skills/oklch-color-workflow/SKILL.md +426 -0
  19. package/dist/template/.pi/skills/production-hardening/SKILL.md +652 -0
  20. package/dist/template/.pi/skills/react-best-practices/SKILL.md +79 -1
  21. package/dist/template/.pi/skills/react-compiler/SKILL.md +237 -0
  22. package/dist/template/.pi/skills/react-hook-form/SKILL.md +374 -0
  23. package/dist/template/.pi/skills/react-server-actions/SKILL.md +299 -0
  24. package/dist/template/.pi/skills/shadcn-ui/SKILL.md +404 -0
  25. package/dist/template/.pi/skills/tanstack-query/SKILL.md +330 -0
  26. package/dist/template/.pi/skills/ui-craft-principles/SKILL.md +564 -0
  27. package/dist/template/.pi/skills/ui-quality-audit/SKILL.md +329 -0
  28. package/dist/template/.pi/skills/v0/SKILL.md +264 -0
  29. package/dist/template/.pi/skills/zustand/SKILL.md +333 -0
  30. package/dist/template/.pi/templates/DESIGN.md +76 -0
  31. package/dist/template/.pi/workflows/INDEX.md +2 -1
  32. package/dist/template/.pi/workflows/frontend-feature-workflow.md +343 -0
  33. package/dist/template/.pi/workflows/quality-loop.md +1 -1
  34. package/package.json +1 -1
  35. package/dist/template/.pi/prompts/loop-check.md +0 -87
  36. package/dist/template/.pi/prompts/loop-init.md +0 -157
  37. package/dist/template/.pi/prompts/loop-review.md +0 -90
  38. package/dist/template/.pi/skills/loop-audit/SKILL.md +0 -141
  39. package/dist/template/.pi/skills/loop-cost/SKILL.md +0 -130
  40. package/dist/template/.pi/skills/loop-engineering/SKILL.md +0 -175
  41. package/dist/template/.pi/templates/loop-github-action.yml +0 -162
  42. package/dist/template/.pi/templates/loop-orchestrator.sh +0 -514
  43. package/dist/template/.pi/templates/loop-orchestrator.test.ts +0 -332
  44. package/dist/template/.pi/templates/loop-orchestrator.ts +0 -936
  45. package/dist/template/.pi/templates/loop-state.json +0 -24
  46. package/dist/template/.pi/templates/loop-state.md +0 -98
  47. package/dist/template/.pi/templates/loop-vision.md +0 -110
  48. /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