@tamsensedev/runner 0.0.0-stage → 0.1.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/LICENSE +5 -0
- package/README.md +45 -2
- package/dist/cli.js +2234 -0
- package/dist/cli.js.map +1 -0
- package/library/.claude-plugin/plugin.json +5 -0
- package/library/THIRD_PARTY_NOTICES.md +123 -0
- package/library/agents/code-architect.md +67 -0
- package/library/agents/code-explorer.md +61 -0
- package/library/agents/code-reviewer.md +73 -0
- package/library/agents/code-simplifier.md +62 -0
- package/library/agents/design-critic.md +52 -0
- package/library/agents/second-opinion.md +64 -0
- package/library/agents/security-auditor.md +86 -0
- package/library/agents/silent-failure-hunter.md +70 -0
- package/library/agents/test-coverage-analyzer.md +78 -0
- package/library/agents/test-runner.md +48 -0
- package/library/agents/ui-tester.md +47 -0
- package/library/licenses/Apache-2.0.txt +202 -0
- package/library/skills/accessibility/SKILL.md +144 -0
- package/library/skills/accessibility/references/wcag-checks.md +168 -0
- package/library/skills/addressing-review-feedback/SKILL.md +109 -0
- package/library/skills/app-screens/SKILL.md +96 -0
- package/library/skills/app-screens/references/shell-kpis-charts.md +108 -0
- package/library/skills/app-screens/references/states-density-phone.md +54 -0
- package/library/skills/app-screens/references/tables-forms-settings.md +104 -0
- package/library/skills/avoiding-generic-ai-design/SKILL.md +92 -0
- package/library/skills/avoiding-generic-ai-design/references/tells.md +112 -0
- package/library/skills/clean-commits/SKILL.md +85 -0
- package/library/skills/design-review/SKILL.md +107 -0
- package/library/skills/design-review/references/critique-passes.md +101 -0
- package/library/skills/design-review/references/screenshot-procedure.md +113 -0
- package/library/skills/differential-security-review/LICENSE +427 -0
- package/library/skills/differential-security-review/SKILL.md +105 -0
- package/library/skills/differential-security-review/references/adversarial.md +115 -0
- package/library/skills/differential-security-review/references/methodology.md +145 -0
- package/library/skills/differential-security-review/references/patterns.md +108 -0
- package/library/skills/false-positive-check/LICENSE +427 -0
- package/library/skills/false-positive-check/SKILL.md +95 -0
- package/library/skills/false-positive-check/references/bug-class-verification.md +128 -0
- package/library/skills/false-positive-check/references/deep-verification.md +113 -0
- package/library/skills/false-positive-check/references/evidence-templates.md +93 -0
- package/library/skills/false-positive-check/references/false-positive-patterns.md +122 -0
- package/library/skills/false-positive-check/references/gate-reviews.md +36 -0
- package/library/skills/false-positive-check/references/standard-verification.md +80 -0
- package/library/skills/focused-implementation/SKILL.md +104 -0
- package/library/skills/implementation-plan/SKILL.md +66 -0
- package/library/skills/interface-design/SKILL.md +144 -0
- package/library/skills/interface-design/references/craft-defaults.md +130 -0
- package/library/skills/interface-design/references/new-direction.md +62 -0
- package/library/skills/interface-design/references/writing.md +52 -0
- package/library/skills/interface-polish/SKILL.md +103 -0
- package/library/skills/interface-polish/references/detail-rules.md +154 -0
- package/library/skills/interface-polish/references/worst-case-data.md +158 -0
- package/library/skills/motion-design/SKILL.md +106 -0
- package/library/skills/motion-design/references/recipes.md +243 -0
- package/library/skills/postgres-performance/SKILL.md +97 -0
- package/library/skills/postgres-performance/references/rules.md +265 -0
- package/library/skills/project-orientation/SKILL.md +77 -0
- package/library/skills/reviewing-changes/SKILL.md +183 -0
- package/library/skills/safe-database-changes/SKILL.md +111 -0
- package/library/skills/safe-database-changes/references/backfills.md +75 -0
- package/library/skills/safe-database-changes/references/migration-tools.md +69 -0
- package/library/skills/safe-database-changes/references/postgres-ddl.md +199 -0
- package/library/skills/security-review/SKILL.md +132 -0
- package/library/skills/security-review/references/checks-by-area.md +107 -0
- package/library/skills/security-review/references/do-not-report.md +86 -0
- package/library/skills/spec-writing/SKILL.md +144 -0
- package/library/skills/systematic-debugging/SKILL.md +130 -0
- package/library/skills/systematic-debugging/references/flaky-tests.md +51 -0
- package/library/skills/systematic-debugging/references/root-cause-tracing.md +55 -0
- package/library/skills/task-interview/SKILL.md +84 -0
- package/library/skills/test-driven-development/SKILL.md +116 -0
- package/library/skills/test-driven-development/references/writing-good-tests.md +85 -0
- package/library/skills/ui-testing/SKILL.md +99 -0
- package/library/skills/ui-testing/references/headless-check.md +262 -0
- package/library/skills/ui-testing/references/playwright-patterns.md +134 -0
- package/library/skills/verification-before-completion/SKILL.md +113 -0
- package/library/skills/web-interface-guidelines/SKILL.md +202 -0
- package/package.json +44 -5
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Detail rules with code
|
|
2
|
+
|
|
3
|
+
Read this when applying a specific polish rule and you want the exact values. Examples are in plain CSS with a Tailwind equivalent where it helps; translate them into the project's styling system and tokens.
|
|
4
|
+
|
|
5
|
+
## Concentric radius
|
|
6
|
+
|
|
7
|
+
```css
|
|
8
|
+
.card { border-radius: 16px; padding: 8px; } /* 8 + 8 */
|
|
9
|
+
.card-inner { border-radius: 8px; }
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Tailwind: `rounded-2xl p-2` (16px radius, 8px padding) on the outer element, `rounded-lg` (8px = 16 - 8) on the inner one.
|
|
13
|
+
|
|
14
|
+
With padding over about 24px, the layers read as separate surfaces; choose each radius from the scale.
|
|
15
|
+
|
|
16
|
+
## Optical alignment
|
|
17
|
+
|
|
18
|
+
```css
|
|
19
|
+
.button-with-trailing-icon { padding-inline: 16px 14px; } /* icon side = text side - 2px */
|
|
20
|
+
.play-button svg { translate: 2px 0; } /* triangle's visual centre */
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
For lopsided icons (stars, carets), adjust the SVG `viewBox` or path so no wrapper offset is needed.
|
|
24
|
+
|
|
25
|
+
## Shadow as border, and elevation
|
|
26
|
+
|
|
27
|
+
```css
|
|
28
|
+
:root {
|
|
29
|
+
--shadow-border:
|
|
30
|
+
0 0 0 1px rgb(0 0 0 / 0.06),
|
|
31
|
+
0 1px 2px -1px rgb(0 0 0 / 0.06),
|
|
32
|
+
0 2px 4px 0 rgb(0 0 0 / 0.04);
|
|
33
|
+
--shadow-border-hover:
|
|
34
|
+
0 0 0 1px rgb(0 0 0 / 0.08),
|
|
35
|
+
0 1px 2px -1px rgb(0 0 0 / 0.08),
|
|
36
|
+
0 2px 4px 0 rgb(0 0 0 / 0.06);
|
|
37
|
+
}
|
|
38
|
+
.dark {
|
|
39
|
+
--shadow-border: 0 0 0 1px rgb(255 255 255 / 0.08);
|
|
40
|
+
--shadow-border-hover: 0 0 0 1px rgb(255 255 255 / 0.13);
|
|
41
|
+
}
|
|
42
|
+
.card { box-shadow: var(--shadow-border); transition: box-shadow 150ms ease-out; }
|
|
43
|
+
.card:hover { box-shadow: var(--shadow-border-hover); }
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
| Prefer a shadow | Keep a border |
|
|
47
|
+
|---|---|
|
|
48
|
+
| Cards and buttons on varied backgrounds | Dividers between list items |
|
|
49
|
+
| Floating layers (menus, popovers, dialogs) | Table cell boundaries |
|
|
50
|
+
| Hover lift on clickable cards | Form input outlines |
|
|
51
|
+
|
|
52
|
+
Use this only if it matches the product's depth strategy; a product built on plain borders keeps borders.
|
|
53
|
+
|
|
54
|
+
## Image outlines
|
|
55
|
+
|
|
56
|
+
```css
|
|
57
|
+
img.media { outline: 1px solid rgb(0 0 0 / 0.1); outline-offset: -1px; }
|
|
58
|
+
.dark img.media { outline-color: rgb(255 255 255 / 0.1); }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Tailwind: `outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10`. Pure black or white only, never a tinted grey from the palette.
|
|
62
|
+
|
|
63
|
+
## Hit areas
|
|
64
|
+
|
|
65
|
+
```css
|
|
66
|
+
.icon-button { position: relative; width: 20px; height: 20px; }
|
|
67
|
+
.icon-button::after {
|
|
68
|
+
content: "";
|
|
69
|
+
position: absolute;
|
|
70
|
+
inset: 50% auto auto 50%;
|
|
71
|
+
width: 44px; height: 44px;
|
|
72
|
+
translate: -50% -50%;
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
If the extended area would overlap a neighbour, shrink it to the largest size that does not.
|
|
77
|
+
|
|
78
|
+
## Type rendering
|
|
79
|
+
|
|
80
|
+
```css
|
|
81
|
+
html { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
|
|
82
|
+
h1, h2, h3 { text-wrap: balance; } /* short text only: browsers balance up to about 6 lines */
|
|
83
|
+
p, li, figcaption { text-wrap: pretty; }
|
|
84
|
+
.num { font-variant-numeric: tabular-nums; }
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`tabular-nums` belongs on counters, timers, prices that update and table columns, not on phone numbers, version strings or decorative display numbers. Some fonts change the look of "1" with it; check it renders well.
|
|
88
|
+
|
|
89
|
+
Font family is not a polish decision: use the product's type system and never add a paid face for polish.
|
|
90
|
+
|
|
91
|
+
## Overflow in rows
|
|
92
|
+
|
|
93
|
+
```css
|
|
94
|
+
.row { display: flex; align-items: center; gap: 12px; }
|
|
95
|
+
.row-avatar { flex-shrink: 0; }
|
|
96
|
+
.row-text { min-width: 0; flex: 1; }
|
|
97
|
+
.row-email { overflow-wrap: anywhere; }
|
|
98
|
+
.row-badge { flex-shrink: 0; white-space: nowrap; }
|
|
99
|
+
.row-menu { flex-shrink: 0; }
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Grid equivalent: `grid-template-columns: auto minmax(0, 1fr) auto`. When text can wrap to several lines, top-align the row (`align-items: flex-start`) so the avatar does not drift.
|
|
103
|
+
|
|
104
|
+
## Icons
|
|
105
|
+
|
|
106
|
+
| Text beside the icon | Stroke on a 24px grid |
|
|
107
|
+
|---|---|
|
|
108
|
+
| Regular (400), 14 to 16px | 1.5px |
|
|
109
|
+
| Medium or semibold (500 to 600) | 2px |
|
|
110
|
+
| Bold (700) or standalone emphasis | 2.5px |
|
|
111
|
+
|
|
112
|
+
```html
|
|
113
|
+
<svg fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">...</svg>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
```css
|
|
117
|
+
.icon-button { color: var(--text-muted); }
|
|
118
|
+
.icon-button:hover { color: var(--text); }
|
|
119
|
+
.icon-button[aria-pressed="true"] { color: var(--accent); }
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Strip hard-coded `fill` and `stroke` colours from imported icons. Inline icons are about 1em to 1.25em. Directional icons (back, forward, chevrons, send) flip in RTL; logos, checkmarks, clocks and media controls do not. Icon-only buttons need an accessible name; decorative icons get `aria-hidden="true"`.
|
|
123
|
+
|
|
124
|
+
## Press feedback and transitions
|
|
125
|
+
|
|
126
|
+
```css
|
|
127
|
+
.button {
|
|
128
|
+
transition-property: transform, background-color;
|
|
129
|
+
transition-duration: 150ms;
|
|
130
|
+
transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);
|
|
131
|
+
}
|
|
132
|
+
.button:active:not(:disabled) { transform: scale(0.97); }
|
|
133
|
+
|
|
134
|
+
@media (hover: hover) and (pointer: fine) {
|
|
135
|
+
.card-link:hover { background-color: var(--surface-hover); }
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Tailwind: `transition-[transform,background-color] duration-150 active:not-disabled:scale-[0.97]`. Never `transition-all`. Use `will-change: transform` only to cure an observed first-frame stutter, never broadly.
|
|
140
|
+
|
|
141
|
+
## Focus
|
|
142
|
+
|
|
143
|
+
```css
|
|
144
|
+
:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
|
|
145
|
+
.input:focus-visible { outline-offset: 0; }
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Never `outline: none` without a replacement. Sticky headers and footers must not cover the focused element: `scroll-padding-top` on the scroll container equal to the header height.
|
|
149
|
+
|
|
150
|
+
## Overlays and clipping
|
|
151
|
+
|
|
152
|
+
A menu inside an `overflow: hidden` or `overflow: auto` ancestor is clipped. Use the project's popover or portal component, `<dialog>`, the `popover` attribute, or `position: fixed` with collision handling. Popovers scale from their trigger (`transform-origin` at the trigger), dialogs from the centre.
|
|
153
|
+
|
|
154
|
+
Sources: adapted from jakubkrehel/make-interfaces-feel-better surfaces, typography, icons, animations and performance references (MIT, changed: values reconciled to scale(0.97), Motion-specific code removed, plain CSS first), emilkowalski/skills break-ui failure signatures (MIT) and pbakaus/impeccable operate reference (Apache-2.0, changed: rewritten).
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Stress-testing with worst-case data
|
|
2
|
+
|
|
3
|
+
Read this when checking that a component or screen survives real data, before finishing UI work or as part of a review.
|
|
4
|
+
|
|
5
|
+
The component was almost certainly built against kind data: "Jane Doe, jane@acme.com, 12 members". Production has Aleksandra Wiśniewska-Kowalczyk, `bartholomew.fitzgerald@northwind-industries-holdings.example.com`, an intern called Jo and a workspace with 1,284 members.
|
|
6
|
+
|
|
7
|
+
## Rules
|
|
8
|
+
|
|
9
|
+
1. **Plausible or schema-backed, never random.** Each value is something a real user could produce, or the actual limit from the validation schema, database column or API contract. Five thousand "a" characters prove nothing. If no limit exists, that is a finding ("unbounded"); test something long but believable.
|
|
10
|
+
2. **Change the data, not the component.** The worst case enters through the same boundary as the demo data: the fixture, mock, story args, seed, or an intercepted API response. Never edit markup or CSS to provoke a break.
|
|
11
|
+
3. **One dataset, many failures.** Spread different worst cases across the first rows (row 1 the long name, row 2 the long email, row 3 the one-letter name), the way real data mixes them.
|
|
12
|
+
4. **Do not stop at long text.** The breaks that ship are the short name, the missing avatar, "1 members", the empty list, the translated badge.
|
|
13
|
+
5. **Leave no test toggles in product code.** In a code step, a worst-case fixture may be committed only where the project keeps fixtures (tests, stories, mocks), following its conventions. In a review step, use only interception or existing fixtures and change nothing.
|
|
14
|
+
|
|
15
|
+
## Phase 1: map what the component renders
|
|
16
|
+
|
|
17
|
+
List every value it shows, with its source, type, limit and whether it is optional:
|
|
18
|
+
|
|
19
|
+
| Field | Source | Type | Limit | Optional |
|
|
20
|
+
|---|---|---|---|---|
|
|
21
|
+
| name | member.name | string | 255 (schema.ts:14) | no |
|
|
22
|
+
| email | member.email | string | none found | no |
|
|
23
|
+
| role | member.title | string | 120 | yes |
|
|
24
|
+
| status | enum | active / invited / expired | n/a | no |
|
|
25
|
+
| count | workspace.memberCount | int | n/a | no |
|
|
26
|
+
|
|
27
|
+
Include the values people forget: counts in headers, relative times, badge and status text, labels that come from data, tooltips, avatar images, and the list length itself. Find limits in validation schemas, migrations, API types and `maxlength` attributes; note where front end and back end disagree.
|
|
28
|
+
|
|
29
|
+
## Phase 2: build the worst case
|
|
30
|
+
|
|
31
|
+
Pick values from the catalog below. Also cover cases that are not one dataset:
|
|
32
|
+
|
|
33
|
+
- **Empty**: zero items, and no results after filtering.
|
|
34
|
+
- **One**: a single item, and every count at exactly 1.
|
|
35
|
+
- **Huge**: the realistic upper bound (1,000 or more rows if unpaginated).
|
|
36
|
+
|
|
37
|
+
## Phase 3: look
|
|
38
|
+
|
|
39
|
+
Check the worst case:
|
|
40
|
+
|
|
41
|
+
- at the component's real container width (a sidebar list is not a full-page list), then at 320px and the widest supported layout;
|
|
42
|
+
- with browser zoom at 200%, because text grows and boxes do not;
|
|
43
|
+
- in dark mode and with `dir="rtl"` if the product supports them.
|
|
44
|
+
|
|
45
|
+
Screenshot both demo and worst case at each width (procedure in `design-review`). Say which findings you saw and which you inferred from code.
|
|
46
|
+
|
|
47
|
+
## Catalog
|
|
48
|
+
|
|
49
|
+
Use `example.com`, `example.org` or `.test` domains so fixtures never point at real inboxes.
|
|
50
|
+
|
|
51
|
+
**People and names**
|
|
52
|
+
|
|
53
|
+
| Value | Breaks |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `Aleksandra Wiśniewska-Kowalczyk` | Long, hyphenated, diacritics; wraps at the hyphen |
|
|
56
|
+
| `Christopher Alexander Montgomery III` | Suffix; naive initials give "CI" |
|
|
57
|
+
| `Jo`, `J` | Near-empty name column; one-letter initials; tiny click target if the name is the link |
|
|
58
|
+
| `Đặng Thị Ngọc Hân` | Stacked diacritics clipped by tight line height with `overflow: hidden` |
|
|
59
|
+
| `王秀英` | No spaces; "first plus last word" initials find one word |
|
|
60
|
+
| `نور الهدى عبد الرحمن` | RTL; punctuation and icons on the wrong side without `dir="auto"` |
|
|
61
|
+
| `Seán O'Brien-Ó Súilleabháin` | Apostrophe and accents: escaping, initials, search |
|
|
62
|
+
| A name starting with an emoji ("Fox" after a fox emoji), or with a skin-tone and joiner sequence before "Priya" | `charAt(0)` returns half a character; slicing splits the sequence |
|
|
63
|
+
| ` Sam Lee ` | Leading, trailing and repeated spaces |
|
|
64
|
+
| missing name | Only an email; the UI needs a fallback |
|
|
65
|
+
|
|
66
|
+
**Emails, URLs, identifiers**
|
|
67
|
+
|
|
68
|
+
| Value | Breaks |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `bartholomew.fitzgerald@northwind-industries-holdings.example.com` | No wrap points; pushes siblings off the row |
|
|
71
|
+
| `a@b.co` | Layout that assumed a long email looks empty |
|
|
72
|
+
| `first.last+billing-notifications@example.com` | Plus addressing rejected or truncated |
|
|
73
|
+
| A long URL with query string | End truncation hides the part that differs |
|
|
74
|
+
| `9f8e7d6c-5b4a-4c3d-8e2f-1a0b9c8d7e6f` | UUID width; candidate for middle truncation |
|
|
75
|
+
| `Q3 Board Deck FINAL (revised) v12 [approved].pdf` | End truncation hides version and extension |
|
|
76
|
+
|
|
77
|
+
**Labels and text from data**
|
|
78
|
+
|
|
79
|
+
| Value | Breaks |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `Senior Product Design Engineer, Platform Infrastructure` | Wraps to three lines in a secondary slot |
|
|
82
|
+
| `Invitation expired 12 days ago` as a badge | Badge wraps or squeezes the name |
|
|
83
|
+
| `Benachrichtigungseinstellungen` | German compound, no break points |
|
|
84
|
+
| French or German UI strings | About 30% longer than English |
|
|
85
|
+
| Twelve tags on one item | Tag rows become a wall; needs "+8" |
|
|
86
|
+
| Empty string or whitespace title | Collapsed heading, zero-height row |
|
|
87
|
+
| `<script>alert(1)</script>`, `&`, `**bold**` | Must render as literal text |
|
|
88
|
+
| A 2,000-character description | Clamping, "show more", textarea growth |
|
|
89
|
+
|
|
90
|
+
**Numbers and money**
|
|
91
|
+
|
|
92
|
+
| Value | Breaks |
|
|
93
|
+
|---|---|
|
|
94
|
+
| 0 | "0 members", empty bars, division by zero in percentages |
|
|
95
|
+
| 1 | "1 members", "1 days ago" |
|
|
96
|
+
| 1284, 1000000 | Missing separators; badge width; consider compact form where precision does not matter |
|
|
97
|
+
| 12345678.9 as currency | Overflowing totals columns |
|
|
98
|
+
| -42.5 | Sign, colour logic, accounting parentheses |
|
|
99
|
+
| 0.1 + 0.2 | `0.30000000000000004` rendered raw |
|
|
100
|
+
| 142%, -3% | Progress bars past their bounds |
|
|
101
|
+
| null, undefined, NaN | Rendered literally |
|
|
102
|
+
| A live value 99 then 100 | Width jump without tabular figures |
|
|
103
|
+
|
|
104
|
+
**Collections**: 0 items; 1 item (grids that look broken with one card); exactly the page size and one more; 1,000+ unpaginated; one item ten times larger than the others; items with identical names.
|
|
105
|
+
|
|
106
|
+
**Time**: now ("0 seconds ago"); 12 days, 11 months, 3 years ago (switch to absolute dates); a future date; `1970-01-01`; a time near midnight in another time zone; a duration of 1,284 hours.
|
|
107
|
+
|
|
108
|
+
**Images**: avatar URL that fails (fallback to initials); no avatar; a 4000 x 200 panorama and a 200 x 4000 strip (need `object-fit: cover`); a dark transparent logo on a dark theme; a slow image (needs fixed dimensions).
|
|
109
|
+
|
|
110
|
+
**States**: loading; API error (no raw `TypeError` text on screen); partial data; every status value at once; no permission; the current user in the list.
|
|
111
|
+
|
|
112
|
+
## Failure signatures
|
|
113
|
+
|
|
114
|
+
| What you see | Cause | Fix |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| Avatar or icon squashed into an oval | Flex child shrinking | `flex-shrink: 0` on fixed-size items |
|
|
117
|
+
| Text overflows instead of wrapping or truncating | `min-width: auto` on a flex or grid child | `min-width: 0`, or `minmax(0, 1fr)` |
|
|
118
|
+
| Email or URL runs past the edge | No break opportunities | `overflow-wrap: anywhere` on that element |
|
|
119
|
+
| Trailing menu pushed off-screen | Middle content took the space | `min-width: 0` on the middle, `flex-shrink: 0` on the action |
|
|
120
|
+
| Badge wraps onto two lines | Badge allowed to shrink | `white-space: nowrap; flex-shrink: 0`, decide what yields |
|
|
121
|
+
| Avatar adrift beside a three-line name | `align-items: center` on rows of varying height | Top-align once text can wrap |
|
|
122
|
+
| Last row cut mid-glyph | Fixed height without scroll affordance | Visible scroll or a fade, intended `overflow` |
|
|
123
|
+
| Heading breaks mid-word | `word-break: break-all` | `overflow-wrap: anywhere` |
|
|
124
|
+
| Wrong initials | `split(' ')[0][0]` | Grapheme-aware initials (`Intl.Segmenter`), first and last word, icon fallback |
|
|
125
|
+
| Orphaned dash where an optional field was | Placeholder for a missing value | Omit the line, or reserve its height on purpose |
|
|
126
|
+
| "1 members" | Hard-coded plural | `Intl.PluralRules` or the project's i18n |
|
|
127
|
+
| Numbers jitter as they update | Proportional figures | `tabular-nums` |
|
|
128
|
+
| `1284`, `NaN`, `undefined` | Raw number rendered | `Intl.NumberFormat`, guard null |
|
|
129
|
+
| Translated button label overflows | Fixed width | Width from content with a `min-width` |
|
|
130
|
+
| Tall scripts clipped | Tight line height with hidden overflow | Looser line height, no clipping on text |
|
|
131
|
+
| Broken-image icon | No error fallback | Fall back to initials or a neutral icon |
|
|
132
|
+
| Truncated text with no way to read it | Ellipsis only | Title or tooltip, and the full value in a detail view |
|
|
133
|
+
| 1,000 rows stutter | Every row rendered | Virtualize or paginate |
|
|
134
|
+
| Raw `<b>`, `&` or `**text**` shown | Wrong escaping layer | Escape once at render; never inject user HTML |
|
|
135
|
+
|
|
136
|
+
## Truncate, wrap or clamp
|
|
137
|
+
|
|
138
|
+
Decide per field:
|
|
139
|
+
|
|
140
|
+
- **Wrap** what identifies something (names, titles in a detail view). Two lines is fine; four means the column is too narrow.
|
|
141
|
+
- **Truncate at the end** for secondary metadata where the start carries the meaning (role, preview text), always with a way to see the full value.
|
|
142
|
+
- **Truncate in the middle** where items differ at the end (file names, paths, hashes, emails sharing a long domain).
|
|
143
|
+
- **Clamp** (`line-clamp: 2`) multi-line previews in cards so heights stay predictable.
|
|
144
|
+
- **Never truncate** numbers, amounts, dates, or anything people compare.
|
|
145
|
+
|
|
146
|
+
## Output
|
|
147
|
+
|
|
148
|
+
| # | Severity | Field | Worst-case value | What happens | Fix (`file:line`) |
|
|
149
|
+
|---|---|---|---|---|---|
|
|
150
|
+
| 1 | Broken | email | `bartholomew.fitzgerald@...example.com` | Menu unreachable at 400px | `min-width: 0` on text column, `overflow-wrap: anywhere` on email |
|
|
151
|
+
| 2 | Ugly | count | 1 | "1 members" | Plural rules |
|
|
152
|
+
| 3 | Fragile | name | n/a | No max length in schema or form | Matching limits in both |
|
|
153
|
+
|
|
154
|
+
Severity: **Broken** (unreadable, unreachable, wrong data), **Ugly** (readable but visibly wrong), **Fragile** (fine now, one realistic step from breaking). Also list what held up, so the test is visibly real.
|
|
155
|
+
|
|
156
|
+
In a code step, fix the breaks; where there are several right answers (truncate or wrap, what an empty role shows), choose by the rules above, apply it, and list the choice in the report. In a review step, report only.
|
|
157
|
+
|
|
158
|
+
Sources: adapted from emilkowalski/skills break-ui SKILL.md and CATALOG.md (MIT, changed: no interactive toggle shipped, no "report and wait for the user" step; code steps fix, review steps report) and pbakaus/impeccable harden reference (Apache-2.0, changed: rewritten).
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: motion-design
|
|
3
|
+
description: Use when adding, changing or reviewing UI animation or transitions: press and hover feedback, menus, dialogs, drawers, toasts, list changes, view transitions, marketing-page motion. Covers whether to animate at all, durations, easing, performance, interruption and reduced motion, CSS first, then the project's motion library.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Motion design
|
|
7
|
+
|
|
8
|
+
Motion should be felt, not watched. Good motion explains a change (where the menu came from, that the click registered, what was removed) and then gets out of the way. Generated UI tends to do the opposite: a fade-up on every block, hover lifts on every card, bounce easing, 400ms dropdowns.
|
|
9
|
+
|
|
10
|
+
The project's motion tokens, existing transitions and installed animation library win over every value here. If the codebase already animates dialogs at 200ms with its own curve, new dialogs do the same.
|
|
11
|
+
|
|
12
|
+
## Decide before you animate
|
|
13
|
+
|
|
14
|
+
### 1. Should this animate at all?
|
|
15
|
+
|
|
16
|
+
| How often people see it | Decision |
|
|
17
|
+
|---|---|
|
|
18
|
+
| 100+ times a day (keyboard shortcuts, command palette, typing, list navigation by keys) | No animation |
|
|
19
|
+
| Tens of times a day (hover, tab switches, row selection) | Instant, or a 100 to 150ms colour or opacity change |
|
|
20
|
+
| Occasionally (menus, dialogs, drawers, toasts, expanding sections) | Standard animation |
|
|
21
|
+
| Rarely (first run, a completed setup, an empty state the first time) | May carry a little more character |
|
|
22
|
+
|
|
23
|
+
Never animate keyboard-initiated actions. Product screens load into the task: no orchestrated page-load sequences.
|
|
24
|
+
|
|
25
|
+
### 2. What is it for?
|
|
26
|
+
|
|
27
|
+
Valid purposes: feedback (the press registered), state (open, selected, saved), spatial continuity (the toast leaves the way it came, the drawer slides from its edge), preventing a jarring jump (content appearing or disappearing), and on marketing pages, explaining how something works. "It looks nice" is not a purpose for something seen often.
|
|
28
|
+
|
|
29
|
+
### 3. Easing
|
|
30
|
+
|
|
31
|
+
- Entering or exiting: **ease-out**. Default curve `cubic-bezier(0.23, 1, 0.32, 1)`.
|
|
32
|
+
- Moving or morphing on screen: **ease-in-out**, `cubic-bezier(0.77, 0, 0.175, 1)`.
|
|
33
|
+
- Hover and colour changes: `ease`.
|
|
34
|
+
- Constant motion (progress, marquee): `linear`.
|
|
35
|
+
- Drawers and sheets: `cubic-bezier(0.32, 0.72, 0, 1)` reads like native mobile sheets.
|
|
36
|
+
- Never `ease-in` for UI: it delays the first frame, exactly when the person is watching. No bounce or elastic curves by reflex.
|
|
37
|
+
|
|
38
|
+
### 4. Duration
|
|
39
|
+
|
|
40
|
+
| Element | Duration |
|
|
41
|
+
|---|---|
|
|
42
|
+
| Press feedback | 100 to 160ms |
|
|
43
|
+
| Hover, colour | 100 to 150ms |
|
|
44
|
+
| Tooltip, small popover | 125 to 200ms |
|
|
45
|
+
| Dropdown, select, menu | 150 to 250ms |
|
|
46
|
+
| Dialog, drawer, sheet | 200 to 300ms (large drawers up to 500ms) |
|
|
47
|
+
| Layout or view transition | 250 to 400ms |
|
|
48
|
+
| Authored moment on a marketing page | 500 to 800ms |
|
|
49
|
+
|
|
50
|
+
UI motion stays under 300ms. Exits are faster and quieter than entrances (about 75% of the enter time, a small offset rather than the full distance).
|
|
51
|
+
|
|
52
|
+
## Rules
|
|
53
|
+
|
|
54
|
+
1. **Animate `transform` and `opacity`.** They skip layout and paint. Animate `width`, `height`, `top`, `left` or margins only with a technique built for it (FLIP, grid-template-rows `0fr` to `1fr`, View Transitions). `filter`, `clip-path` and shadows are fine on small, bounded elements.
|
|
55
|
+
2. **Never `transition: all`.** Name the properties.
|
|
56
|
+
3. **Interruptible by default.** Use CSS transitions (or springs) for anything the person can trigger again mid-way; they retarget. Keyframes restart from zero; keep them for one-shot sequences.
|
|
57
|
+
4. **Nothing appears from nothing.** Enter from `scale(0.95)` to `scale(0.97)` with opacity 0, never `scale(0)`.
|
|
58
|
+
5. **Origin-aware popovers.** Menus, popovers and tooltips scale from their trigger (`transform-origin` at the trigger side). Dialogs stay centred.
|
|
59
|
+
6. **Stagger only real lists**, 30 to 80ms between items, total under about 300ms, and never block interaction while it plays.
|
|
60
|
+
7. **Press feedback**: `scale(0.97)` on `:active` for buttons (not below 0.95).
|
|
61
|
+
8. **Tooltips**: a short delay before the first one; once one is open, neighbours open instantly with no animation.
|
|
62
|
+
9. **Hover motion only for fine pointers**: wrap it in `@media (hover: hover) and (pointer: fine)`.
|
|
63
|
+
10. **Content is visible by default.** If a script fails or an observer never fires, nothing stays hidden. Scroll reveals start from an already-visible state or have a no-script fallback.
|
|
64
|
+
11. **Reduced motion means fewer and gentler, not none.** Under `prefers-reduced-motion: reduce`, remove movement, scaling and parallax; keep short opacity and colour changes that confirm state. Stop decorative loops. Autoplaying motion over 5 seconds needs a pause control.
|
|
65
|
+
12. **Motion is never the only signal.** Every animated state change also has a static cue (colour, icon, text).
|
|
66
|
+
13. **`will-change` only to cure an observed first-frame stutter**, on the element and only during the animation.
|
|
67
|
+
14. **No new dependency for an effect the stack can already express.** CSS transitions, `@starting-style`, the Web Animations API and View Transitions cover most UI needs.
|
|
68
|
+
|
|
69
|
+
## Implement with what the project has
|
|
70
|
+
|
|
71
|
+
In order: CSS transitions and keyframes; the Web Animations API for programmatic control; the project's existing library (Vue `<Transition>`, Svelte transitions, Angular animations, Motion for React, GSAP); View Transitions for continuity between states or routes. Recipes for each are in [references/recipes.md](references/recipes.md).
|
|
72
|
+
|
|
73
|
+
## Marketing pages
|
|
74
|
+
|
|
75
|
+
A page read once may carry one authored moment: a hero sequence, one interactive demo, a single scroll-linked reveal whose scroll relationship means something. One strong idea beats an identical fade-up on every section. Keep it under about 800ms, make it skippable by scrolling, give it a reduced-motion version, and keep the page complete without it.
|
|
76
|
+
|
|
77
|
+
## Check
|
|
78
|
+
|
|
79
|
+
- Play it at slow speed (DevTools Animations panel at 10%, or multiply durations by 5 temporarily). Look for two states overlapping in a crossfade, wrong origins, properties out of sync, a jump at the end.
|
|
80
|
+
- Trigger it twice quickly and mid-way: it reverses smoothly, nothing sticks.
|
|
81
|
+
- Toggle reduced motion (DevTools rendering panel, or Playwright `reducedMotion: 'reduce'`): state changes still read.
|
|
82
|
+
- Throttle the CPU 4x: no dropped frames on the common path.
|
|
83
|
+
- A screenshot taken mid-load shows no blank bands where a reveal did not fire.
|
|
84
|
+
|
|
85
|
+
## Common mistakes
|
|
86
|
+
|
|
87
|
+
| Mistake | Fix |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `transition: all 300ms` | `transition: transform 200ms var(--ease-out), opacity 200ms var(--ease-out)` |
|
|
90
|
+
| `scale(0)` entrance | `scale(0.95)` with opacity 0 |
|
|
91
|
+
| `ease-in` on a dropdown | ease-out, custom curve |
|
|
92
|
+
| Popover scaling from its centre | `transform-origin` at the trigger |
|
|
93
|
+
| Animation on a keyboard shortcut | Remove it |
|
|
94
|
+
| 400ms dropdown | 150 to 250ms |
|
|
95
|
+
| Keyframes on a toggle that can be re-triggered | A transition |
|
|
96
|
+
| Hover scale that fires on tap | Gate with the hover and pointer media query |
|
|
97
|
+
| Every section fades up on scroll | Remove; one authored moment at most, on marketing pages |
|
|
98
|
+
| Exit as slow as the enter | Shorter, smaller exit |
|
|
99
|
+
|
|
100
|
+
## Output in reviews
|
|
101
|
+
|
|
102
|
+
A table with **Before**, **After** and **Why**, one row per issue, citing `file:line`.
|
|
103
|
+
|
|
104
|
+
Related: `interface-polish`, `interface-design`, `accessibility`.
|
|
105
|
+
|
|
106
|
+
Sources: adapted from emilkowalski/skills emil-design-eng (MIT, changed: framework-neutral, React and Motion specifics moved to examples, "initial response" step removed), jakubkrehel/make-interfaces-feel-better animations reference (MIT) and pbakaus/impeccable animate and operate references (Apache-2.0, changed: rewritten, ask steps removed).
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# Motion recipes
|
|
2
|
+
|
|
3
|
+
Read this when implementing a specific transition. Use the project's existing motion tokens and library if it has them; these recipes are CSS first, then examples in common libraries. Do not add a library to use a recipe.
|
|
4
|
+
|
|
5
|
+
## Tokens
|
|
6
|
+
|
|
7
|
+
```css
|
|
8
|
+
:root {
|
|
9
|
+
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
|
10
|
+
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
|
|
11
|
+
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
|
|
12
|
+
--duration-press: 150ms;
|
|
13
|
+
--duration-popover: 180ms;
|
|
14
|
+
--duration-dialog: 240ms;
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Reduced motion pattern
|
|
19
|
+
|
|
20
|
+
Define movement only for people who have not asked to reduce it; keep the opacity change for everyone.
|
|
21
|
+
|
|
22
|
+
```css
|
|
23
|
+
.toast { transition: opacity 200ms ease; }
|
|
24
|
+
|
|
25
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
26
|
+
.toast {
|
|
27
|
+
transition:
|
|
28
|
+
opacity 200ms var(--ease-out),
|
|
29
|
+
transform 200ms var(--ease-out);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Press feedback
|
|
35
|
+
|
|
36
|
+
```css
|
|
37
|
+
.button {
|
|
38
|
+
transition: transform var(--duration-press) var(--ease-out);
|
|
39
|
+
}
|
|
40
|
+
.button:active:not(:disabled) { transform: scale(0.97); }
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Popover or menu (native popover, no JavaScript)
|
|
44
|
+
|
|
45
|
+
```css
|
|
46
|
+
.menu[popover] {
|
|
47
|
+
transform-origin: top left;
|
|
48
|
+
opacity: 0;
|
|
49
|
+
transform: scale(0.97);
|
|
50
|
+
transition:
|
|
51
|
+
opacity var(--duration-popover) var(--ease-out),
|
|
52
|
+
transform var(--duration-popover) var(--ease-out),
|
|
53
|
+
overlay var(--duration-popover) allow-discrete,
|
|
54
|
+
display var(--duration-popover) allow-discrete;
|
|
55
|
+
}
|
|
56
|
+
.menu[popover]:popover-open { opacity: 1; transform: scale(1); }
|
|
57
|
+
|
|
58
|
+
@starting-style {
|
|
59
|
+
.menu[popover]:popover-open { opacity: 0; transform: scale(0.97); }
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Browsers without `@starting-style` simply show the menu without animation. Set `transform-origin` to the side of the trigger. With a headless library, use the origin variable it exposes (for example `var(--radix-popover-content-transform-origin)` or `var(--transform-origin)`).
|
|
64
|
+
|
|
65
|
+
## Dialog
|
|
66
|
+
|
|
67
|
+
Same pattern on `dialog[open]`, centred origin, `--duration-dialog`, plus a backdrop fade:
|
|
68
|
+
|
|
69
|
+
```css
|
|
70
|
+
dialog::backdrop { background: rgb(0 0 0 / 0.4); transition: opacity var(--duration-dialog) ease; }
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Exit about 75% of the enter duration.
|
|
74
|
+
|
|
75
|
+
## Drawer or bottom sheet
|
|
76
|
+
|
|
77
|
+
```css
|
|
78
|
+
.sheet {
|
|
79
|
+
transform: translateY(100%);
|
|
80
|
+
transition: transform 300ms var(--ease-drawer);
|
|
81
|
+
}
|
|
82
|
+
.sheet[data-open="true"] { transform: translateY(0); }
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Percent translations move by the element's own size, so they work at any height. Add `overscroll-behavior: contain` on the sheet's scroll area.
|
|
86
|
+
|
|
87
|
+
## Expand and collapse without animating height
|
|
88
|
+
|
|
89
|
+
```css
|
|
90
|
+
.collapsible {
|
|
91
|
+
display: grid;
|
|
92
|
+
grid-template-rows: 0fr;
|
|
93
|
+
transition: grid-template-rows 250ms var(--ease-out);
|
|
94
|
+
}
|
|
95
|
+
.collapsible[data-open="true"] { grid-template-rows: 1fr; }
|
|
96
|
+
.collapsible > .inner { overflow: hidden; }
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Toast
|
|
100
|
+
|
|
101
|
+
Enter from the edge it lives on, leave the same way, a little faster:
|
|
102
|
+
|
|
103
|
+
```css
|
|
104
|
+
.toast { transform: translateY(0); opacity: 1; }
|
|
105
|
+
.toast[data-state="entering"], .toast[data-state="leaving"] { transform: translateY(100%); opacity: 0; }
|
|
106
|
+
.toast[data-state="leaving"] { transition-duration: 150ms; }
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Pause auto-dismiss timers while the toast is hovered or the tab is hidden.
|
|
110
|
+
|
|
111
|
+
## Icon swap (copy to copied, play to pause)
|
|
112
|
+
|
|
113
|
+
Keep both icons in the DOM, one positioned over the other, and cross-fade:
|
|
114
|
+
|
|
115
|
+
```css
|
|
116
|
+
.icon-swap { position: relative; display: inline-grid; }
|
|
117
|
+
.icon-swap > * {
|
|
118
|
+
grid-area: 1 / 1;
|
|
119
|
+
transition:
|
|
120
|
+
opacity 250ms cubic-bezier(0.2, 0, 0, 1),
|
|
121
|
+
transform 250ms cubic-bezier(0.2, 0, 0, 1),
|
|
122
|
+
filter 250ms cubic-bezier(0.2, 0, 0, 1);
|
|
123
|
+
}
|
|
124
|
+
.icon-swap > [data-hidden="true"] { opacity: 0; transform: scale(0.25); filter: blur(4px); }
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Skeleton
|
|
128
|
+
|
|
129
|
+
A flat muted block at the final size. If the project pulses skeletons, use a slow opacity pulse (1.5 to 2s) and stop it under reduced motion. No shimmer gradient sweeping across the screen.
|
|
130
|
+
|
|
131
|
+
## Web Animations API
|
|
132
|
+
|
|
133
|
+
```js
|
|
134
|
+
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
135
|
+
element.animate(
|
|
136
|
+
reduce
|
|
137
|
+
? [{ opacity: 0 }, { opacity: 1 }]
|
|
138
|
+
: [{ opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'none' }],
|
|
139
|
+
{ duration: 200, easing: 'cubic-bezier(0.23, 1, 0.32, 1)', fill: 'backwards' },
|
|
140
|
+
)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## View Transitions
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
function update(change) {
|
|
147
|
+
if (!document.startViewTransition) return change()
|
|
148
|
+
document.startViewTransition(change)
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
```css
|
|
153
|
+
::view-transition-old(root),
|
|
154
|
+
::view-transition-new(root) { animation-duration: 200ms; }
|
|
155
|
+
|
|
156
|
+
@media (prefers-reduced-motion: reduce) {
|
|
157
|
+
::view-transition-group(*),
|
|
158
|
+
::view-transition-old(*),
|
|
159
|
+
::view-transition-new(*) { animation: none; }
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Use them when continuity between two states is the point (a card expanding into its detail), not on every route change. Many routers (Astro, Nuxt, SvelteKit, Next.js) have their own switch for this; use it.
|
|
164
|
+
|
|
165
|
+
## Vue
|
|
166
|
+
|
|
167
|
+
```vue
|
|
168
|
+
<template>
|
|
169
|
+
<Transition name="pop">
|
|
170
|
+
<div v-if="open" class="menu">...</div>
|
|
171
|
+
</Transition>
|
|
172
|
+
</template>
|
|
173
|
+
|
|
174
|
+
<style>
|
|
175
|
+
.pop-enter-active { transition: opacity 180ms var(--ease-out), transform 180ms var(--ease-out); }
|
|
176
|
+
.pop-leave-active { transition: opacity 120ms var(--ease-out), transform 120ms var(--ease-out); }
|
|
177
|
+
.pop-enter-from,
|
|
178
|
+
.pop-leave-to { opacity: 0; transform: scale(0.97); }
|
|
179
|
+
</style>
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
For lists, `<TransitionGroup>` with a `.list-move { transition: transform 200ms var(--ease-out); }` class animates reordering.
|
|
183
|
+
|
|
184
|
+
## Svelte
|
|
185
|
+
|
|
186
|
+
```svelte
|
|
187
|
+
<script>
|
|
188
|
+
import { fly } from 'svelte/transition'
|
|
189
|
+
import { cubicOut } from 'svelte/easing'
|
|
190
|
+
</script>
|
|
191
|
+
|
|
192
|
+
{#if open}
|
|
193
|
+
<div class="menu" transition:fly={{ y: -4, duration: 180, easing: cubicOut }}>...</div>
|
|
194
|
+
{/if}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Motion for React
|
|
198
|
+
|
|
199
|
+
Only if `motion` or `framer-motion` is already installed; import from whichever the project uses.
|
|
200
|
+
|
|
201
|
+
```jsx
|
|
202
|
+
import { AnimatePresence, motion } from 'motion/react'
|
|
203
|
+
|
|
204
|
+
<AnimatePresence initial={false}>
|
|
205
|
+
{open && (
|
|
206
|
+
<motion.div
|
|
207
|
+
key="panel"
|
|
208
|
+
initial={{ opacity: 0, transform: 'scale(0.97)' }}
|
|
209
|
+
animate={{ opacity: 1, transform: 'scale(1)' }}
|
|
210
|
+
exit={{ opacity: 0, transform: 'scale(0.97)', transition: { duration: 0.12 } }}
|
|
211
|
+
transition={{ duration: 0.18, ease: [0.23, 1, 0.32, 1] }}
|
|
212
|
+
/>
|
|
213
|
+
)}
|
|
214
|
+
</AnimatePresence>
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`initial={false}` stops elements already present on first render from animating in. Full `transform` strings stay on the compositor when the main thread is busy; the `x` and `y` shorthands do not. For gestures and drags, springs (`{ type: 'spring', duration: 0.3, bounce: 0 }`) keep their velocity when interrupted; keep bounce at 0 in product UI. `useReducedMotion()` returns the user's preference.
|
|
218
|
+
|
|
219
|
+
## GSAP
|
|
220
|
+
|
|
221
|
+
Only if GSAP is already installed.
|
|
222
|
+
|
|
223
|
+
```js
|
|
224
|
+
import { gsap } from 'gsap'
|
|
225
|
+
|
|
226
|
+
const mm = gsap.matchMedia()
|
|
227
|
+
mm.add('(prefers-reduced-motion: no-preference)', () => {
|
|
228
|
+
gsap.from('.step', { opacity: 0, y: 12, duration: 0.4, ease: 'power3.out', stagger: 0.06 })
|
|
229
|
+
})
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
`gsap.matchMedia()` reverts the animation when the media query stops matching. Keep scroll-triggered work for marketing pages, and give it a version that leaves content visible.
|
|
233
|
+
|
|
234
|
+
## Drag and gestures
|
|
235
|
+
|
|
236
|
+
- Capture the pointer once dragging starts (`setPointerCapture`) so the drag continues outside the element.
|
|
237
|
+
- Ignore extra touch points after the drag begins.
|
|
238
|
+
- Dismiss on velocity as well as distance (a quick flick is enough).
|
|
239
|
+
- Past a boundary, apply friction instead of a hard stop.
|
|
240
|
+
- On `pointercancel`, `lostpointercapture` and window `blur`, clear the dragging state so the next drag works.
|
|
241
|
+
- Offer a click or keyboard alternative for every gesture.
|
|
242
|
+
|
|
243
|
+
Sources: adapted from emilkowalski/skills emil-design-eng (MIT, changed: examples made framework-neutral, Vue, Svelte and GSAP examples added), jakubkrehel/make-interfaces-feel-better animations reference (MIT, changed: icon swap duration aligned to 250ms, CSS grid variant) and pbakaus/impeccable animate and harden references (Apache-2.0, changed: rewritten).
|