oh-my-customcode 0.57.0 → 0.58.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.
@@ -0,0 +1,278 @@
1
+ # Color and Contrast
2
+
3
+ > Reference: Impeccable Design Language — https://github.com/pbakaus/impeccable (Apache 2.0)
4
+
5
+ ---
6
+
7
+ ## OKLCH: The Preferred Color Model
8
+
9
+ OKLCH (Oklab Lightness, Chroma, Hue) is a perceptually uniform color space. Unlike HSL, equal numeric changes in OKLCH produce equal perceived differences — making it the right tool for generating accessible, harmonious palettes programmatically.
10
+
11
+ ### Structure
12
+
13
+ ```
14
+ oklch(lightness% chroma hue)
15
+ ```
16
+
17
+ | Channel | Range | Description |
18
+ |---------|-------|-------------|
19
+ | `lightness` | 0%–100% | Perceived brightness |
20
+ | `chroma` | 0–0.4 (approx) | Color saturation/vividness |
21
+ | `hue` | 0–360 | Color angle (red=25, yellow=90, green=145, cyan=200, blue=250, purple=310) |
22
+
23
+ ### Why not HSL?
24
+
25
+ HSL's lightness channel is not perceptually uniform. A blue at `hsl(250, 70%, 50%)` and a yellow at `hsl(60, 70%, 50%)` have the same numeric lightness but very different perceived brightness. This makes HSL palettes that "look right" on the screen require constant manual tuning.
26
+
27
+ OKLCH fixes this: a blue at `oklch(50% 0.15 250)` and a yellow at `oklch(50% 0.15 90)` appear equally bright.
28
+
29
+ ### Browser support
30
+
31
+ OKLCH is supported in all modern browsers (Chrome 111+, Firefox 113+, Safari 15.4+). For legacy support, provide an HSL fallback:
32
+
33
+ ```css
34
+ color: hsl(250, 60%, 40%); /* fallback */
35
+ color: oklch(40% 0.15 250); /* modern */
36
+ ```
37
+
38
+ ---
39
+
40
+ ## Chroma Constraints
41
+
42
+ ### Reduce chroma near white and black extremes
43
+
44
+ At very high or very low lightness values, maximum chroma cannot be rendered — the color clips to the display gamut. Reduce chroma as lightness approaches 0% or 100%:
45
+
46
+ | Lightness range | Max chroma (approx) |
47
+ |-----------------|---------------------|
48
+ | 10%–20% | 0.04–0.08 |
49
+ | 20%–40% | 0.08–0.20 |
50
+ | 40%–60% | 0.15–0.35 |
51
+ | 60%–80% | 0.10–0.25 |
52
+ | 80%–95% | 0.03–0.10 |
53
+
54
+ Check rendered output with [OKLCH.com](https://oklch.com/) — the gamut boundary is shown visually.
55
+
56
+ ---
57
+
58
+ ## Neutrals: Tinted, Not Pure Gray
59
+
60
+ Pure grays (chroma 0) appear cold and disconnected from the UI's color palette. Tinting neutrals with a low-chroma version of the brand hue creates cohesion.
61
+
62
+ ### Formula
63
+
64
+ Use the brand hue with chroma 0.01–0.02 for neutrals:
65
+
66
+ ```css
67
+ :root {
68
+ /* Brand hue: 250 (blue) */
69
+ --neutral-900: oklch(12% 0.01 250);
70
+ --neutral-800: oklch(20% 0.01 250);
71
+ --neutral-700: oklch(30% 0.01 250);
72
+ --neutral-600: oklch(40% 0.01 250);
73
+ --neutral-500: oklch(50% 0.01 250);
74
+ --neutral-400: oklch(60% 0.01 250);
75
+ --neutral-300: oklch(72% 0.01 250);
76
+ --neutral-200: oklch(84% 0.01 250);
77
+ --neutral-100: oklch(93% 0.01 250);
78
+ --neutral-50: oklch(97% 0.01 250);
79
+ }
80
+ ```
81
+
82
+ ### Temperature
83
+
84
+ | Hue angle | Temperature | Effect |
85
+ |-----------|-------------|--------|
86
+ | ~60° | Warm | Approachable, editorial |
87
+ | ~250° | Cool | Technical, precise, professional |
88
+
89
+ Warm neutrals (cream, off-white) suit consumer and lifestyle products. Cool neutrals suit developer tools, dashboards, and data products.
90
+
91
+ ---
92
+
93
+ ## Palette Architecture
94
+
95
+ ### Components
96
+
97
+ | Component | Count | Purpose |
98
+ |-----------|-------|---------|
99
+ | Primary | 1 color, 3–5 shades | Brand identity, CTAs, interactive elements |
100
+ | Neutral | 9–11 shades | Backgrounds, borders, text |
101
+ | Semantic | 4 colors (success, warning, danger, info), 2–3 shades each | State communication |
102
+ | Surface | 2–3 variants | Background layering (base, raised, overlay) |
103
+
104
+ ### Primary palette (5 shades)
105
+
106
+ ```css
107
+ :root {
108
+ --primary-50: oklch(95% 0.05 250); /* tint, hover backgrounds */
109
+ --primary-200: oklch(80% 0.10 250); /* light states */
110
+ --primary-500: oklch(55% 0.20 250); /* primary action */
111
+ --primary-700: oklch(38% 0.18 250); /* pressed, dark variant */
112
+ --primary-900: oklch(22% 0.12 250); /* text on light bg */
113
+ }
114
+ ```
115
+
116
+ ### Semantic palette
117
+
118
+ ```css
119
+ :root {
120
+ /* Success */
121
+ --success-light: oklch(92% 0.06 145);
122
+ --success: oklch(50% 0.18 145);
123
+ --success-dark: oklch(35% 0.15 145);
124
+
125
+ /* Warning */
126
+ --warning-light: oklch(93% 0.08 85);
127
+ --warning: oklch(65% 0.20 85);
128
+ --warning-dark: oklch(45% 0.18 85);
129
+
130
+ /* Danger */
131
+ --danger-light: oklch(93% 0.06 25);
132
+ --danger: oklch(50% 0.20 25);
133
+ --danger-dark: oklch(35% 0.18 25);
134
+
135
+ /* Info */
136
+ --info-light: oklch(93% 0.05 230);
137
+ --info: oklch(52% 0.18 230);
138
+ --info-dark: oklch(38% 0.16 230);
139
+ }
140
+ ```
141
+
142
+ ---
143
+
144
+ ## The 60-30-10 Rule
145
+
146
+ Visual weight should be distributed to create hierarchy without chaos:
147
+
148
+ | Proportion | Role | Example |
149
+ |------------|------|---------|
150
+ | 60% | Dominant (neutral backgrounds) | Page background, card surfaces |
151
+ | 30% | Secondary (supporting) | Sidebar, navigation, secondary panels |
152
+ | 10% | Accent (brand, CTAs) | Buttons, links, highlights, icons |
153
+
154
+ Violating this ratio — for example, a 40% brand-color background — overwhelms the interface and makes it harder to identify interactive elements.
155
+
156
+ ---
157
+
158
+ ## WCAG Contrast Requirements
159
+
160
+ Contrast ratio is calculated between foreground and background luminance. Use a contrast checker to verify all text/background combinations.
161
+
162
+ ### Minimum ratios
163
+
164
+ | Element | WCAG AA | WCAG AAA |
165
+ |---------|---------|----------|
166
+ | Body text (< 18px / < 14px bold) | 4.5:1 | 7:1 |
167
+ | Large text (≥ 18px / ≥ 14px bold) | 3:1 | 4.5:1 |
168
+ | UI components (borders, icons, input outlines) | 3:1 | 4.5:1 |
169
+
170
+ ### Practical targets
171
+
172
+ - Body text: aim for 7:1 (AAA) — the difference in effort is minimal and accessibility is significantly better
173
+ - Large headings: 4.5:1 minimum
174
+ - Placeholder text: WCAG requires 4.5:1; placeholders are not exempt despite their decorative role
175
+ - Disabled elements: WCAG exempts disabled controls from contrast requirements, but aim for 3:1 as a courtesy
176
+
177
+ ### Anti-patterns
178
+
179
+ | Anti-pattern | Problem | Fix |
180
+ |--------------|---------|-----|
181
+ | Light gray text on white | Classic failure — looks designed, fails AA | Use `oklch(45% 0.01 250)` on white for safe gray |
182
+ | Gray text on colored background | Double unpredictability — two variables affecting contrast | Test with a contrast checker, not by eye |
183
+ | Red on green | Colorblind failure (deuteranopia/protanopia) | Add pattern or icon; do not rely on color alone |
184
+ | Blue on red | Chromatic aberration causes vibration | Add lightness contrast; avoid hue-only contrast |
185
+ | Yellow on white | Low contrast despite perceived brightness | Yellow on white fails AA unless very dark yellow |
186
+ | Thin text over images | Impossible to guarantee; background varies | Add text shadow, overlay panel, or blur backdrop |
187
+
188
+ ### Testing tools
189
+
190
+ - [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
191
+ - Chrome DevTools: Elements panel → Accessibility → Contrast
192
+ - Firefox DevTools: Accessibility panel
193
+ - Polypane: Built-in contrast checking across all breakpoints
194
+
195
+ ---
196
+
197
+ ## No Pure Gray or Black
198
+
199
+ Pure grays (`oklch(N% 0 0)` or `#808080`) read as cold and disconnected. Tinted grays integrate into the palette.
200
+
201
+ ### Rule: chroma 0.005–0.01 minimum
202
+
203
+ ```css
204
+ /* WRONG: pure gray */
205
+ color: oklch(50% 0 0);
206
+
207
+ /* CORRECT: tinted neutral */
208
+ color: oklch(50% 0.01 250);
209
+ ```
210
+
211
+ ### No pure black
212
+
213
+ `#000000` or `oklch(0% 0 0)` is rarely appropriate. High contrast without tint creates harshness. Instead:
214
+
215
+ ```css
216
+ /* WRONG */
217
+ --text-primary: #000000;
218
+
219
+ /* CORRECT: near-black with subtle tint */
220
+ --text-primary: oklch(12% 0.01 250);
221
+ ```
222
+
223
+ ---
224
+
225
+ ## Dark Mode
226
+
227
+ Dark mode is NOT a color inversion. Inversion breaks contrast relationships and produces garish results. Instead, dark mode requires thoughtfully redesigned color relationships.
228
+
229
+ ### Principles
230
+
231
+ **Lighter surfaces indicate depth.** In light mode, shadows indicate elevation. In dark mode, lighter backgrounds indicate elevation:
232
+
233
+ | Elevation | Light mode | Dark mode |
234
+ |-----------|-----------|-----------|
235
+ | Page background | Lightest | Darkest |
236
+ | Card | +shadow | Slightly lighter |
237
+ | Modal/overlay | +deeper shadow | Lighter still |
238
+ | Tooltip | Darkest shadow | Lightest surface |
239
+
240
+ **Slightly desaturated accents.** Saturated colors are harder to look at on dark backgrounds for extended periods. Reduce chroma by 0.02–0.04:
241
+
242
+ ```css
243
+ @media (prefers-color-scheme: dark) {
244
+ --primary-500: oklch(62% 0.16 250); /* was 0.20 in light mode */
245
+ }
246
+ ```
247
+
248
+ **Dark gray, never pure black.** Use 12–18% lightness for the base background:
249
+
250
+ ```css
251
+ @media (prefers-color-scheme: dark) {
252
+ --surface-base: oklch(14% 0.01 250); /* main background */
253
+ --surface-raised: oklch(18% 0.01 250); /* cards */
254
+ --surface-overlay: oklch(22% 0.01 250); /* modals */
255
+ }
256
+ ```
257
+
258
+ **Redefine semantic tokens, do not invert them.** Semantic tokens exist precisely for this purpose:
259
+
260
+ ```css
261
+ :root {
262
+ --text-body: oklch(25% 0.01 250);
263
+ --text-muted: oklch(50% 0.01 250);
264
+ --bg-base: oklch(98% 0.01 250);
265
+ }
266
+
267
+ @media (prefers-color-scheme: dark) {
268
+ --text-body: oklch(90% 0.01 250);
269
+ --text-muted: oklch(65% 0.01 250);
270
+ --bg-base: oklch(14% 0.01 250);
271
+ }
272
+ ```
273
+
274
+ ### Dark mode testing
275
+
276
+ - DevTools: Rendering panel → Emulate CSS media feature prefers-color-scheme
277
+ - Test WCAG contrast ratios in dark mode independently — they differ from light mode
278
+ - Test with vision emulation filters (protanopia, deuteranopia, achromatopsia) in DevTools
@@ -0,0 +1,12 @@
1
+ name: impeccable-design
2
+ description: AI design language reference — typography, color, motion, and UX writing for production-grade UI
3
+ source:
4
+ type: external
5
+ origin: github
6
+ url: https://github.com/pbakaus/impeccable
7
+ license: Apache-2.0
8
+ documents:
9
+ - typography.md
10
+ - color-and-contrast.md
11
+ - motion-design.md
12
+ - ux-writing.md
@@ -0,0 +1,390 @@
1
+ # Motion Design
2
+
3
+ > Reference: Impeccable Design Language — https://github.com/pbakaus/impeccable (Apache 2.0)
4
+
5
+ ---
6
+
7
+ ## The 100/300/500 Rule
8
+
9
+ Animation duration should match the conceptual weight of the change. Too fast and users miss feedback; too slow and the interface feels sluggish.
10
+
11
+ ### Duration tiers
12
+
13
+ | Tier | Duration | Use cases |
14
+ |------|----------|-----------|
15
+ | Feedback | 100–150ms | Button press, checkbox toggle, hover state, ripple |
16
+ | State change | 200–300ms | Dropdown open, tooltip appear, tab switch, accordion |
17
+ | Structural | 300–500ms | Page transition, modal open, sidebar expand |
18
+ | Entry / onboarding | 500–800ms | Hero animations, first-run sequences, loading complete |
19
+
20
+ ### Exit animations are shorter
21
+
22
+ Elements leaving the screen should animate out at roughly 75% of their entrance duration. The user's attention has already moved on:
23
+
24
+ ```css
25
+ .modal-enter { animation-duration: 300ms; }
26
+ .modal-exit { animation-duration: 225ms; } /* 75% of 300ms */
27
+
28
+ .dropdown-enter { animation-duration: 200ms; }
29
+ .dropdown-exit { animation-duration: 150ms; }
30
+ ```
31
+
32
+ ---
33
+
34
+ ## Easing Functions
35
+
36
+ Generic browser easings (`ease`, `ease-in`, `ease-out`, `ease-in-out`) are functional but imprecise. Custom cubic-bezier curves produce more polished results.
37
+
38
+ ### The three essential curves
39
+
40
+ **Ease-out** — for elements appearing on screen (entering, expanding):
41
+ ```css
42
+ /* Fast start, gentle landing */
43
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
44
+ ```
45
+
46
+ **Ease-in** — for elements leaving screen (exiting, collapsing):
47
+ ```css
48
+ /* Gradual acceleration, fast exit */
49
+ animation-timing-function: cubic-bezier(0.7, 0, 0.84, 0);
50
+ ```
51
+
52
+ **Ease-in-out** — for bidirectional transitions (sliding between states):
53
+ ```css
54
+ /* Symmetrical: slow start, fast middle, slow end */
55
+ animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1);
56
+ ```
57
+
58
+ ### Exponential curves (more expressive)
59
+
60
+ | Curve | CSS | Character |
61
+ |-------|-----|-----------|
62
+ | Quart-out | `cubic-bezier(0.25, 1, 0.5, 1)` | Smooth default, good for most UI |
63
+ | Quint-out | `cubic-bezier(0.22, 1, 0.36, 1)` | Dramatic, large structural changes |
64
+ | Expo-out | `cubic-bezier(0.16, 1, 0.3, 1)` | Snappy, high-energy feedback |
65
+
66
+ ### CSS custom properties for easing tokens
67
+
68
+ ```css
69
+ :root {
70
+ --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
71
+ --ease-in: cubic-bezier(0.7, 0, 0.84, 0);
72
+ --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
73
+ --ease-spring: cubic-bezier(0.25, 1, 0.5, 1);
74
+ }
75
+ ```
76
+
77
+ ### Anti-pattern: bounce and elastic
78
+
79
+ Bounce and elastic easings were popular in the early 2010s. They now read as amateurish and dated. They also perform poorly for accessibility (vestibular disorders). Do not use them:
80
+
81
+ ```css
82
+ /* WRONG: dated, amateurish */
83
+ animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1); /* overshoot */
84
+
85
+ /* CORRECT: expo-out is energetic without bouncing */
86
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
87
+ ```
88
+
89
+ ---
90
+
91
+ ## Performance: Only Animate transform and opacity
92
+
93
+ Layout-triggering properties (`width`, `height`, `top`, `left`, `margin`, `padding`) force the browser to recalculate layout on every frame — expensive and jank-prone. Only `transform` and `opacity` skip layout and paint, running entirely on the GPU compositor.
94
+
95
+ ### Safe properties
96
+
97
+ ```css
98
+ /* CORRECT: compositor-only, 60fps */
99
+ .panel { transform: translateX(-100%); opacity: 0; }
100
+ .panel.open { transform: translateX(0); opacity: 1; }
101
+ ```
102
+
103
+ ### Avoid layout-triggering animations
104
+
105
+ ```css
106
+ /* WRONG: triggers layout recalculation every frame */
107
+ .panel { left: -300px; }
108
+ .panel.open { left: 0; }
109
+
110
+ /* WRONG: forces paint */
111
+ .card { background-color: #fff; }
112
+ .card:hover { background-color: #f5f5f5; } /* fine for hover, bad inside keyframes */
113
+ ```
114
+
115
+ ### Animating height with CSS grid
116
+
117
+ Animating `height: 0` to `height: auto` is a common requirement (accordion, expand/collapse) that cannot use `transform` directly. The cleanest CSS-only solution uses `grid-template-rows`:
118
+
119
+ ```css
120
+ .accordion-content {
121
+ display: grid;
122
+ grid-template-rows: 0fr;
123
+ transition: grid-template-rows 250ms var(--ease-out);
124
+ }
125
+
126
+ .accordion-content.open {
127
+ grid-template-rows: 1fr;
128
+ }
129
+
130
+ .accordion-content > div {
131
+ overflow: hidden; /* required for 0fr to clip content */
132
+ }
133
+ ```
134
+
135
+ ---
136
+
137
+ ## Staggered Animations
138
+
139
+ Staggering applies progressively increasing delays to a list of elements, creating a wave effect. It communicates that items belong together while adding visual interest.
140
+
141
+ ### CSS custom properties approach
142
+
143
+ ```css
144
+ .list-item {
145
+ animation: fade-up 300ms var(--ease-out) both;
146
+ animation-delay: calc(var(--i) * 50ms);
147
+ }
148
+ ```
149
+
150
+ Set `--i` on each element:
151
+
152
+ ```html
153
+ <li class="list-item" style="--i: 0">First</li>
154
+ <li class="list-item" style="--i: 1">Second</li>
155
+ <li class="list-item" style="--i: 2">Third</li>
156
+ ```
157
+
158
+ Or set it in JavaScript:
159
+
160
+ ```js
161
+ document.querySelectorAll('.list-item').forEach((el, i) => {
162
+ el.style.setProperty('--i', i);
163
+ });
164
+ ```
165
+
166
+ ### Cap total stagger duration at 500ms
167
+
168
+ A list of 20 items staggered at 50ms each takes 1000ms to complete — too long. Cap the maximum total delay:
169
+
170
+ ```css
171
+ animation-delay: calc(min(var(--i), 8) * 50ms); /* max 400ms total */
172
+ ```
173
+
174
+ Or limit at the last item: stagger interval × item count should not exceed 500ms.
175
+
176
+ ---
177
+
178
+ ## Accessibility: prefers-reduced-motion
179
+
180
+ Approximately 35% of adults over 40 have vestibular disorders that can be triggered by parallax, sliding transitions, and spinning elements. The `prefers-reduced-motion` media query is not optional.
181
+
182
+ ### User statistics
183
+
184
+ This is not a fringe case. `prefers-reduced-motion: reduce` affects a significant portion of users — including those who enable it for performance reasons on low-power devices.
185
+
186
+ ### Replace spatial motion with crossfades
187
+
188
+ The principle: preserve the informational purpose of the animation while removing the vestibular trigger.
189
+
190
+ ```css
191
+ @keyframes slide-in {
192
+ from { transform: translateY(20px); opacity: 0; }
193
+ to { transform: translateY(0); opacity: 1; }
194
+ }
195
+
196
+ @keyframes fade-in {
197
+ from { opacity: 0; }
198
+ to { opacity: 1; }
199
+ }
200
+
201
+ .panel {
202
+ animation: slide-in 300ms var(--ease-out);
203
+ }
204
+
205
+ @media (prefers-reduced-motion: reduce) {
206
+ .panel {
207
+ animation: fade-in 150ms linear; /* shorter, opacity-only */
208
+ }
209
+ }
210
+ ```
211
+
212
+ ### Preserve functional animations
213
+
214
+ Some animations communicate state changes that users need (progress bars, loading spinners, form validation). These should be preserved even for reduced-motion users — reduce their speed and intensity, do not eliminate them:
215
+
216
+ ```css
217
+ @media (prefers-reduced-motion: reduce) {
218
+ .spinner {
219
+ /* Slow down, keep the spin so user knows loading is happening */
220
+ animation-duration: 2s;
221
+ }
222
+
223
+ .progress-bar {
224
+ /* Shorten transition but keep it */
225
+ transition-duration: 50ms;
226
+ }
227
+ }
228
+ ```
229
+
230
+ ### Global reset approach
231
+
232
+ A pragmatic approach for existing codebases:
233
+
234
+ ```css
235
+ @media (prefers-reduced-motion: reduce) {
236
+ *,
237
+ *::before,
238
+ *::after {
239
+ animation-duration: 0.01ms !important;
240
+ animation-iteration-count: 1 !important;
241
+ transition-duration: 0.01ms !important;
242
+ }
243
+ }
244
+ ```
245
+
246
+ This is a blunt instrument. Prefer targeted overrides where functional animations need preservation.
247
+
248
+ ---
249
+
250
+ ## Perceived Performance
251
+
252
+ Animation can make interfaces feel faster even when task completion time is identical. These techniques exploit how the brain perceives wait time.
253
+
254
+ ### The 80ms threshold
255
+
256
+ Users perceive delays under 80ms as instantaneous. Responses between 80–100ms feel slightly delayed but acceptable. Above 200ms, users consciously notice the wait.
257
+
258
+ Design principle: use the delay budget wisely. If a backend call takes 300ms, an optimistic UI update at 0ms prevents the user from ever perceiving the delay.
259
+
260
+ ### Active vs passive waiting
261
+
262
+ A spinner saying "Loading..." is passive waiting — the user is frozen. A progress bar, animated skeleton screen, or optimistic UI update is active — the user feels progress is happening.
263
+
264
+ - Skeleton screens reduce perceived wait time by 10–30% compared to blank space
265
+ - Content that appears progressively (top to bottom) feels faster than all-at-once reveals
266
+
267
+ ### Preemptive transitions
268
+
269
+ Start a transition before the user's action completes. Hover states that begin animating at hover start (not click) make actions feel faster:
270
+
271
+ ```css
272
+ .button {
273
+ transition: background-color 150ms var(--ease-out),
274
+ transform 100ms var(--ease-out);
275
+ }
276
+
277
+ .button:hover {
278
+ /* Animate on hover, not just on click */
279
+ background-color: var(--primary-600);
280
+ transform: translateY(-1px);
281
+ }
282
+
283
+ .button:active {
284
+ transform: translateY(0);
285
+ transition-duration: 50ms;
286
+ }
287
+ ```
288
+
289
+ ### Optimistic UI
290
+
291
+ Update the UI immediately on user action, then sync with the server. Show the final state first and roll back only on error:
292
+
293
+ ```js
294
+ // Optimistic update pattern
295
+ async function toggleLike(postId) {
296
+ // 1. Update UI immediately
297
+ setLiked(true);
298
+ setCount(prev => prev + 1);
299
+
300
+ try {
301
+ // 2. Sync with server
302
+ await api.like(postId);
303
+ } catch {
304
+ // 3. Roll back on failure
305
+ setLiked(false);
306
+ setCount(prev => prev - 1);
307
+ showError('Could not save — please try again');
308
+ }
309
+ }
310
+ ```
311
+
312
+ ---
313
+
314
+ ## Performance Implementation
315
+
316
+ ### will-change: use on trigger, not permanently
317
+
318
+ `will-change` promotes an element to its own GPU layer. Overuse wastes GPU memory and can slow rendering:
319
+
320
+ ```css
321
+ /* WRONG: always promoted, wastes GPU memory */
322
+ .card { will-change: transform; }
323
+
324
+ /* CORRECT: promote only when animation is about to happen */
325
+ .card:hover { will-change: transform; }
326
+
327
+ /* Or in JS: add/remove on animation start/end */
328
+ el.addEventListener('mouseenter', () => el.style.willChange = 'transform');
329
+ el.addEventListener('animationend', () => el.style.willChange = 'auto');
330
+ ```
331
+
332
+ ### Intersection Observer for scroll animations
333
+
334
+ Never use scroll event listeners for animation triggers — they fire on every scroll event and block the main thread. Use `IntersectionObserver`:
335
+
336
+ ```js
337
+ const observer = new IntersectionObserver((entries) => {
338
+ entries.forEach(entry => {
339
+ if (entry.isIntersecting) {
340
+ entry.target.classList.add('animate-in');
341
+ observer.unobserve(entry.target); // stop observing once triggered
342
+ }
343
+ });
344
+ }, { threshold: 0.1 });
345
+
346
+ document.querySelectorAll('.animate-on-scroll').forEach(el => observer.observe(el));
347
+ ```
348
+
349
+ ### Motion tokens
350
+
351
+ Centralize all durations and easings as tokens to enable systematic changes and theming:
352
+
353
+ ```css
354
+ :root {
355
+ /* Duration */
356
+ --duration-fast: 100ms;
357
+ --duration-normal: 200ms;
358
+ --duration-slow: 350ms;
359
+ --duration-deliberate: 500ms;
360
+
361
+ /* Easing */
362
+ --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
363
+ --ease-in: cubic-bezier(0.7, 0, 0.84, 0);
364
+ --ease-inout: cubic-bezier(0.65, 0, 0.35, 1);
365
+
366
+ /* Reduce motion override */
367
+ --duration-motion-safe: 200ms;
368
+ }
369
+
370
+ @media (prefers-reduced-motion: reduce) {
371
+ :root {
372
+ --duration-motion-safe: 0.01ms;
373
+ }
374
+ }
375
+ ```
376
+
377
+ ---
378
+
379
+ ## Anti-Patterns
380
+
381
+ | Anti-pattern | Problem | Fix |
382
+ |--------------|---------|-----|
383
+ | Animating everything | Dilutes meaning; users stop noticing what's important | Reserve motion for state changes that need emphasis |
384
+ | Feedback animation > 500ms | Feels broken; user re-clicks, creates double-trigger bugs | Keep feedback under 200ms |
385
+ | No reduced-motion support | Triggers vestibular disorders for ~35% of adults over 40 | Always implement `prefers-reduced-motion` |
386
+ | Bounce and elastic easing | Dated, amateurish, vestibular risk | Use expo-out for energy |
387
+ | `left`/`top`/`height` in keyframes | Triggers layout recalc every frame, causes jank | Use `transform` + `opacity` only |
388
+ | Stagger > 500ms total | Users wait for the list to finish before acting | Cap at 500ms total stagger |
389
+ | `will-change` on static elements | Wastes GPU memory, can slow render | Apply only during/before animation |
390
+ | Scroll listener for animations | Blocks main thread, causes jank | Use `IntersectionObserver` |