@brunosps00/dev-workflow 2.5.0 → 2.7.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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +101 -0
- package/README.md +8 -5
- package/lib/doctor.js +63 -11
- package/lib/hooks.js +112 -27
- package/lib/init.js +27 -11
- package/lib/migrate-skills.js +189 -39
- package/lib/removed-bundled-skills.js +4 -2
- package/lib/skill-registry.js +12 -2
- package/lib/uninstall.js +63 -7
- package/package.json +1 -1
- package/scaffold/en/agent-instructions.md +1 -1
- package/scaffold/en/commands/dw-brainstorm.md +2 -2
- package/scaffold/en/commands/dw-bugfix.md +1 -1
- package/scaffold/en/commands/dw-commit.md +1 -1
- package/scaffold/en/commands/dw-dockerize.md +2 -2
- package/scaffold/en/commands/dw-functional-doc.md +1 -1
- package/scaffold/en/commands/dw-harness-audit.md +1 -1
- package/scaffold/en/commands/dw-intel.md +1 -1
- package/scaffold/en/commands/dw-learn.md +1 -1
- package/scaffold/en/commands/dw-new-project.md +3 -3
- package/scaffold/en/commands/dw-plan.md +2 -2
- package/scaffold/en/commands/dw-qa.md +6 -5
- package/scaffold/en/commands/dw-redesign-ui.md +3 -3
- package/scaffold/en/commands/dw-review.md +2 -2
- package/scaffold/en/commands/dw-skill-health.md +24 -1
- package/scaffold/en/commands/dw-update.md +28 -11
- package/scaffold/en/references/invariants.md +7 -2
- package/scaffold/en/templates/project-onepager.md +1 -1
- package/scaffold/pt-br/agent-instructions.md +1 -1
- package/scaffold/pt-br/commands/dw-brainstorm.md +2 -2
- package/scaffold/pt-br/commands/dw-bugfix.md +1 -1
- package/scaffold/pt-br/commands/dw-commit.md +1 -1
- package/scaffold/pt-br/commands/dw-dockerize.md +2 -2
- package/scaffold/pt-br/commands/dw-functional-doc.md +1 -1
- package/scaffold/pt-br/commands/dw-harness-audit.md +1 -1
- package/scaffold/pt-br/commands/dw-intel.md +1 -1
- package/scaffold/pt-br/commands/dw-learn.md +1 -1
- package/scaffold/pt-br/commands/dw-new-project.md +3 -3
- package/scaffold/pt-br/commands/dw-plan.md +2 -2
- package/scaffold/pt-br/commands/dw-qa.md +6 -5
- package/scaffold/pt-br/commands/dw-redesign-ui.md +3 -3
- package/scaffold/pt-br/commands/dw-review.md +2 -2
- package/scaffold/pt-br/commands/dw-skill-health.md +24 -1
- package/scaffold/pt-br/commands/dw-update.md +28 -11
- package/scaffold/pt-br/references/invariants.md +8 -2
- package/scaffold/pt-br/templates/project-onepager.md +1 -1
- package/scaffold/scripts/hooks/git-guardrails.mjs +3 -2
- package/scaffold/scripts/hooks/session-cost.mjs +62 -21
- package/scaffold/scripts/hooks/statusline.mjs +14 -11
- package/scaffold/skills/docker-compose-recipes/references/compose-composition.md +1 -1
- package/scaffold/skills/dw-llm-eval/SKILL.md +3 -1
- package/scaffold/skills/dw-llm-eval/references/integration-with-dev-workflow-commands-detail.md +2 -2
- package/scaffold/skills/dw-llm-eval/references/reference-dataset.md +57 -3
- package/scaffold/skills/dw-ui-discipline/SKILL.md +8 -5
- package/scaffold/skills/dw-ui-discipline/references/accessibility-floor.md +20 -8
- package/scaffold/skills/dw-ui-discipline/references/content-stress.md +246 -0
- package/scaffold/skills/dw-ui-discipline/references/motion.md +205 -35
- package/scaffold/skills/dw-ui-discipline/references/responsive.md +44 -2
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# Content stress — break the UI with realistic worst-case data
|
|
2
|
+
|
|
3
|
+
> Adapted from the `break-ui` skill in [`emilkowalski/skills`](https://github.com/emilkowalski/skills)
|
|
4
|
+
> (MIT © 2026 Emil Kowalski). The worst-case catalog, failure signatures and truncate/wrap/clamp rule come
|
|
5
|
+
> from there, condensed. Injection at the data boundary, severity mapping and routing into `/dw-qa`,
|
|
6
|
+
> `/dw-review` and `/dw-redesign-ui` are written for dev-workflow.
|
|
7
|
+
|
|
8
|
+
The state matrix (`state-matrix.md`) asks *which states* a surface has. This reference asks the other
|
|
9
|
+
question: what happens when the data inside a state is the data real users actually produce. Demo data is
|
|
10
|
+
chosen, usually without anyone noticing, to make the design look good: `Jane Doe`, `jane@acme.com`,
|
|
11
|
+
`12 members`, every optional field filled in. Production has `Aleksandra Wiśniewska-Kowalczyk`, an email
|
|
12
|
+
with a 40-character domain, an intern called `Jo`, and a workspace of 1,284 members.
|
|
13
|
+
|
|
14
|
+
## Two failure modes (the first is worse)
|
|
15
|
+
|
|
16
|
+
1. **Nonsense data.** `"aaaaaaaaaaaaaaaaaaaaaaaa"` and 5,000-character names prove nothing, and the team rightly
|
|
17
|
+
stops listening. Every worst-case value must be something a real user could produce, or the actual
|
|
18
|
+
limit the backend accepts.
|
|
19
|
+
2. **Stopping at long text.** The breaks that ship are usually the short ones: the one-letter name, the
|
|
20
|
+
missing avatar, the count of exactly 1 (`1 members`), the empty list, the badge whose text got
|
|
21
|
+
translated.
|
|
22
|
+
|
|
23
|
+
## Rules
|
|
24
|
+
|
|
25
|
+
1. **Plausible or schema-backed, never random.** Each value is a realistic example or the real limit from
|
|
26
|
+
the validation schema (Zod, Yup, Valibot), the database column, the API contract or the form
|
|
27
|
+
`maxLength`. No limit found is itself a finding: record the field as `unbounded`. A 50-character input
|
|
28
|
+
that saves to a 255-character column will eventually receive 255 characters through an import or the
|
|
29
|
+
API.
|
|
30
|
+
2. **Change the data, not the component.** The worst case enters through the same boundary the demo data
|
|
31
|
+
does: the fixture, the mock, the story args, the API stub, or a network route in the browser test. Never
|
|
32
|
+
hand-edit markup or CSS to produce a break; that tests the edit, not the component.
|
|
33
|
+
3. **One dataset, many failures.** Spread different failures across the first rows of a list (row 1 the
|
|
34
|
+
long name, row 2 the unbreakable email, row 3 the one-letter name), the way real data mixes them.
|
|
35
|
+
4. **Dev-only.** A worst-case switch (`?data=worst`, a story, a prototype route) never ships to production.
|
|
36
|
+
5. **Report before fixing.** Some breaks are design decisions (truncate or wrap? hide the role or reserve
|
|
37
|
+
its line?). List every break with a proposed fix; fixing is the owning command's call.
|
|
38
|
+
6. **Keep the fixture.** It is the regression test for the next person who touches the component.
|
|
39
|
+
7. **Fixture domains:** use `example.com`, `example.org` or `.test` so a fixture never points at a real
|
|
40
|
+
inbox or site.
|
|
41
|
+
|
|
42
|
+
## Step 1 — map the surface
|
|
43
|
+
|
|
44
|
+
List every value the component renders, with its source and limit:
|
|
45
|
+
|
|
46
|
+
| Field | Source | Type | Limit | Optional? |
|
|
47
|
+
|---|---|---|---|---|
|
|
48
|
+
| `name` | `member.name` | string | 255 (`schema.ts:14`) | No |
|
|
49
|
+
| `email` | `member.email` | string | unbounded | No |
|
|
50
|
+
| `role` | `member.title` | string | 120 | Yes |
|
|
51
|
+
| `count` | `workspace.memberCount` | int | — | No |
|
|
52
|
+
|
|
53
|
+
Include the values people forget: counts in headers, relative timestamps, badge and status text, button
|
|
54
|
+
labels that come from data, tooltips, avatar images, and the list itself (its length is a value too).
|
|
55
|
+
|
|
56
|
+
**Done when** every rendered value is in the table with a source and a limit or `unbounded`.
|
|
57
|
+
|
|
58
|
+
## Step 2 — build the worst case from the catalog
|
|
59
|
+
|
|
60
|
+
Pick from the rows that apply to the fields in the table.
|
|
61
|
+
|
|
62
|
+
### People and names
|
|
63
|
+
|
|
64
|
+
| Value | Breaks |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `Aleksandra Wiśniewska-Kowalczyk` | Long, hyphenated, diacritics; wraps, and the hyphen is a break point |
|
|
67
|
+
| `Christopher Alexander Montgomery III` | Suffix; naive first + last initials give `CI` |
|
|
68
|
+
| `Jo` / `J` | Name column mostly empty; naive initials, tiny click target when the name is the link |
|
|
69
|
+
| `Đặng Thị Ngọc Hân` | Stacked diacritics clipped by tight `line-height` + `overflow: hidden` |
|
|
70
|
+
| `王秀英` | CJK, no spaces; "first + last word" initials finds one word |
|
|
71
|
+
| `نور الهدى عبد الرحمن` | RTL; punctuation and icons land on the wrong side without `dir="auto"` |
|
|
72
|
+
| `María José de la Cruz y Fernández` | Lowercase particles; initials and last-name sorting |
|
|
73
|
+
| `🦊 Fox` / `👩🏽💻 Priya` | Emoji first: `.charAt(0)` returns half a surrogate pair; ZWJ sequences split when sliced |
|
|
74
|
+
| ` Sam Lee ` | Leading, trailing, repeated spaces; initials from empty words |
|
|
75
|
+
| *(missing)* | Only an email exists; the UI must fall back to something |
|
|
76
|
+
|
|
77
|
+
### Emails, URLs, identifiers (unbreakable strings)
|
|
78
|
+
|
|
79
|
+
| Value | Breaks |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `bartholomew.fitzgerald@northwind-industries-holdings.example.com` | Pushes every sibling off the row without `overflow-wrap: anywhere` |
|
|
82
|
+
| `a@b.co` | Layout that assumed a long email looks empty |
|
|
83
|
+
| `first.last+billing-notifications@example.com` | Validation rejecting `+`; truncation hiding the meaningful part |
|
|
84
|
+
| A 100-character URL with path and query | Overflow; end-truncation hides the part that differs |
|
|
85
|
+
| `9f8e7d6c-5b4a-4c3d-8e2f-1a0b9c8d7e6f` | UUID; middle-truncation candidate |
|
|
86
|
+
| `Q3 Board Deck — FINAL (revised) v12 [approved].pdf` | End-truncation hides version and extension |
|
|
87
|
+
|
|
88
|
+
### Labels and copy from data
|
|
89
|
+
|
|
90
|
+
| Value | Breaks |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `Senior Product Design Engineer, Platform Infrastructure` | Wraps to three lines in a secondary slot |
|
|
93
|
+
| `Invitation expired 12 days ago` | Long status badge squeezes the name column |
|
|
94
|
+
| `Benachrichtigungseinstellungen` | 30-character German compound, no break opportunity |
|
|
95
|
+
| French or German translation of a short label | UI copy runs ~30% longer in translation |
|
|
96
|
+
| Twelve tags on one item / one 45-character tag | Tag wall without a `+N` overflow; a tag wider than its container |
|
|
97
|
+
| `Untitled` / empty string / ` ` | Collapsed heading, zero-height row |
|
|
98
|
+
| `<script>alert(1)</script>` / `&` / `**bold**` | Must render as literal text |
|
|
99
|
+
| Newline inside a single-line field | Doubles row height or is silently dropped |
|
|
100
|
+
| A 2,000-character pasted description | Clamp, "show more", textarea growth |
|
|
101
|
+
|
|
102
|
+
### Numbers and money
|
|
103
|
+
|
|
104
|
+
| Value | Breaks |
|
|
105
|
+
|---|---|
|
|
106
|
+
| `0` / `1` | Zero states, division by zero in percentages; `1 members`, `1 days ago` |
|
|
107
|
+
| `1284` / `1000000` | Thousands separator; badge width (consider compact `1M` where precision does not matter) |
|
|
108
|
+
| `12345678.9` as currency | Overflows totals columns |
|
|
109
|
+
| `-42.5`, `142%`, `-3%` | Sign and color logic; bars and meters past their bounds |
|
|
110
|
+
| `0.1 + 0.2`, `null`, `undefined`, `NaN` | Rendered raw |
|
|
111
|
+
| `1.284` (de-DE) vs `1,284` (en-US) | Hardcoded separators are wrong for half the world |
|
|
112
|
+
| A value that changes live (`99` → `100`) | Width jump and jitter without `tabular-nums` |
|
|
113
|
+
|
|
114
|
+
### Collections
|
|
115
|
+
|
|
116
|
+
| Value | Breaks |
|
|
117
|
+
|---|---|
|
|
118
|
+
| 0 items / 1 item | The empty state (and whether one exists); grids that look broken with one card |
|
|
119
|
+
| Exactly page size, and page size + 1 | `Showing 40 of 40`, an empty page 2 |
|
|
120
|
+
| 1,000+ items unpaginated | Scroll performance, memory, `Showing 40 of 1,284` copy |
|
|
121
|
+
| One item 10× larger than the others | Rows and masonry stretching to the tallest item |
|
|
122
|
+
| Items with identical names | Lists where the name is the only distinguishing field |
|
|
123
|
+
|
|
124
|
+
### Time
|
|
125
|
+
|
|
126
|
+
| Value | Breaks |
|
|
127
|
+
|---|---|
|
|
128
|
+
| Now | `0 seconds ago` instead of "just now" |
|
|
129
|
+
| 12 days, 11 months, 3 years ago | Relative time past about a week should become an absolute date |
|
|
130
|
+
| A future date | `-3 days ago` |
|
|
131
|
+
| `1970-01-01` | A zero timestamp shown as a real date |
|
|
132
|
+
| `2025-12-31T23:30:00-08:00` | A different day in UTC and in the user's timezone |
|
|
133
|
+
|
|
134
|
+
Use `Intl.RelativeTimeFormat` and `Intl.DateTimeFormat`, not hand-built strings.
|
|
135
|
+
|
|
136
|
+
### Images and media
|
|
137
|
+
|
|
138
|
+
| Value | Breaks |
|
|
139
|
+
|---|---|
|
|
140
|
+
| Avatar URL that 404s / no avatar | Broken-image icon; the initials fallback's size and color parity |
|
|
141
|
+
| 4000×200 or 200×4000 image | Distortion without `object-fit: cover`; blown-out card height |
|
|
142
|
+
| Dark or transparent logo in dark mode | Invisible on the background |
|
|
143
|
+
| Slow image without dimensions | Layout shift without fixed size or `aspect-ratio` |
|
|
144
|
+
|
|
145
|
+
### States mixed into one list
|
|
146
|
+
|
|
147
|
+
Partial data (some optional fields filled, others not), every enum status at once, a row without
|
|
148
|
+
permission, the current user in the list (`You`, actions that should not apply to yourself), and an API
|
|
149
|
+
error rendered raw (`TypeError: Cannot read properties of undefined`).
|
|
150
|
+
|
|
151
|
+
## Step 3 — inject at the boundary
|
|
152
|
+
|
|
153
|
+
Pick the first that exists, in this order:
|
|
154
|
+
|
|
155
|
+
1. The project's own mock layer: fixtures, MSW handlers, Storybook stories, a seed script.
|
|
156
|
+
2. A network route in the browser test (`page.route()` in Playwright) returning the worst-case payload.
|
|
157
|
+
This changes no application code and is the default for `/dw-qa`.
|
|
158
|
+
3. A dev-only switch (`?data=worst` read where the fixture is chosen), only when the owning command is
|
|
159
|
+
allowed to edit code (`/dw-redesign-ui` implement step, `/dw-run`).
|
|
160
|
+
|
|
161
|
+
Also cover the cases one dataset cannot hold: **empty** (0 items), **one** (single item, every count at
|
|
162
|
+
exactly 1) and **huge** (the realistic upper bound, 1,000+ rows when unpaginated).
|
|
163
|
+
|
|
164
|
+
## Step 4 — break it
|
|
165
|
+
|
|
166
|
+
View the worst case and check each failure signature:
|
|
167
|
+
|
|
168
|
+
- at the component's **real container width** (a sidebar list is not a full-page list), then at **320px**
|
|
169
|
+
and at the **widest** layout it supports;
|
|
170
|
+
- at **200% browser zoom** (or a raised root font size), because text grows and fixed boxes do not;
|
|
171
|
+
- in **dark mode** and **RTL** (`dir="rtl"` on a wrapper) when the product supports either;
|
|
172
|
+
- on **touch**: hover-only actions (the `•••` that appears on hover) are unreachable.
|
|
173
|
+
|
|
174
|
+
Screenshot each state when browser tooling exists. Without it, reason from the CSS and say which findings
|
|
175
|
+
were seen and which were inferred.
|
|
176
|
+
|
|
177
|
+
### Failure signatures
|
|
178
|
+
|
|
179
|
+
| What you see | Cause | Fix |
|
|
180
|
+
|---|---|---|
|
|
181
|
+
| Avatar or icon squished into an oval | Flex child shrinking | `flex-shrink: 0` on fixed-size boxes |
|
|
182
|
+
| Text overflows its box | Flex/grid child keeps `min-width: auto` | `min-width: 0` on the text column (`minmax(0, 1fr)` in grid) |
|
|
183
|
+
| Email or URL runs past the edge | No break opportunities | `overflow-wrap: anywhere` on that element |
|
|
184
|
+
| Trailing action pushed off-screen | Middle content took all the space | `min-width: 0` on the middle, `flex-shrink: 0` on the action |
|
|
185
|
+
| Badge wraps onto two lines | Badge allowed to shrink | `white-space: nowrap; flex-shrink: 0`, and decide what yields instead |
|
|
186
|
+
| Avatar adrift next to a three-line name | `align-items: center` on rows of varying height | `align-items: flex-start` once text can wrap |
|
|
187
|
+
| Heading breaks mid-word | `word-break: break-all` | `overflow-wrap: anywhere` breaks only when it must |
|
|
188
|
+
| Wrong initials (`J` for Jo, `CI`, `�`) | `.split(' ')[0][0]`, `.charAt(0)` | Grapheme clusters via `Intl.Segmenter`, first + last word, fallback icon |
|
|
189
|
+
| Orphaned `—` or empty line | Placeholder rendered for a missing optional field | Omit the line, or reserve its height on purpose |
|
|
190
|
+
| `1 members`, `0 member` | Hardcoded plural | `Intl.PluralRules` or per-count strings |
|
|
191
|
+
| Numbers jitter, columns misalign | Proportional figures | `font-variant-numeric: tabular-nums` |
|
|
192
|
+
| `1284`, `1,284.000000001`, `NaN` | Raw number rendered | `Intl.NumberFormat` with the user's locale; guard null |
|
|
193
|
+
| Translated button label overflows | Fixed-width button | `min-width` and content width, never a fixed `width` |
|
|
194
|
+
| Diacritics or tall scripts clipped | Tight `line-height` + `overflow: hidden` | Looser `line-height`, no clipping on text boxes |
|
|
195
|
+
| Broken-image icon in the avatar | No error fallback | Fall back to initials; `object-fit: cover` |
|
|
196
|
+
| Truncated text with no way to read it | `text-overflow: ellipsis` and nothing else | `title` or tooltip, plus the full value elsewhere |
|
|
197
|
+
| Last row cut mid-glyph | Fixed height without scroll affordance | Visible scrollbar or fade mask; confirm `overflow` is intended |
|
|
198
|
+
| 1,000 rows stutter | Every row rendered | Virtualize or paginate, and say which |
|
|
199
|
+
| Raw `<b>`, `&`, `**text**` on screen | Wrong escaping layer | Escape once, at render; never `dangerouslySetInnerHTML` user data |
|
|
200
|
+
|
|
201
|
+
### Truncate, wrap or clamp — decide per field
|
|
202
|
+
|
|
203
|
+
- **Wrap** text the user needs whole to identify something (names, titles in a detail view). Two lines is
|
|
204
|
+
usually fine; four means the column is too narrow.
|
|
205
|
+
- **Truncate at the end** secondary metadata whose start carries the meaning (role, description, message
|
|
206
|
+
preview), always with a way to see the full value.
|
|
207
|
+
- **Truncate in the middle** when items differ at the *end*: file names, emails sharing a domain, paths,
|
|
208
|
+
hashes. End-truncation makes them identical.
|
|
209
|
+
- **Clamp** (`line-clamp: 2`) multi-line previews in cards, so card heights stay predictable.
|
|
210
|
+
- **Never truncate** numbers, amounts, dates, or anything the user compares.
|
|
211
|
+
|
|
212
|
+
## Severity mapping
|
|
213
|
+
|
|
214
|
+
| Content-stress class | dev-workflow severity | Meaning |
|
|
215
|
+
|---|---|---|
|
|
216
|
+
| Broken | high | Content unreadable, action unreachable, or wrong data shown |
|
|
217
|
+
| Ugly | medium | Readable but visibly wrong: squished avatar, wrapped badge |
|
|
218
|
+
| Fragile | low | Fine now, one realistic step from breaking: no limit, no fallback |
|
|
219
|
+
|
|
220
|
+
Raise to critical only when the break hides or misstates money, permissions, or destructive actions.
|
|
221
|
+
|
|
222
|
+
## Static signals for code review
|
|
223
|
+
|
|
224
|
+
`/dw-review` cannot render, but these patterns in a UI diff are candidates (each still goes through
|
|
225
|
+
`dw-review-rigor`):
|
|
226
|
+
|
|
227
|
+
- Initials built with `.split(' ')`, `[0]`, `.charAt(0)` or `.slice(0, 1)` on a name.
|
|
228
|
+
- A count interpolated next to a fixed plural noun (`` `${n} members` ``).
|
|
229
|
+
- `text-overflow: ellipsis` / `truncate` with no `title`, tooltip or detail path.
|
|
230
|
+
- A fixed `width` on a button or badge whose label comes from data or translation.
|
|
231
|
+
- A flex row with an avatar or trailing action and no `flex-shrink: 0`, or a text column with no
|
|
232
|
+
`min-width: 0`.
|
|
233
|
+
- Numbers rendered without `Intl.NumberFormat` / dates without `Intl.DateTimeFormat`.
|
|
234
|
+
- `dangerouslySetInnerHTML` (or `v-html`, `innerHTML`) fed by user or API data — also a security finding.
|
|
235
|
+
- A new string field rendered in UI with no limit in the schema (`Fragile`).
|
|
236
|
+
|
|
237
|
+
## Output
|
|
238
|
+
|
|
239
|
+
One row per break, worst first:
|
|
240
|
+
|
|
241
|
+
| # | Severity | Field | Worst-case value | What happens | Fix (`file:line`) |
|
|
242
|
+
|---|---|---|---|---|---|
|
|
243
|
+
|
|
244
|
+
Then **Decisions for you** (breaks with more than one right answer, one line each with a recommendation)
|
|
245
|
+
and **What held up** (worst cases the component already handles — proof the test was real, and a list of
|
|
246
|
+
what not to touch).
|
|
@@ -1,52 +1,222 @@
|
|
|
1
|
-
# Motion — easing, duration,
|
|
1
|
+
# Motion — whether to animate, easing, duration, physicality, performance
|
|
2
2
|
|
|
3
|
-
>
|
|
4
|
-
> Anthropic's frontend-design skill).
|
|
3
|
+
> First version adapted from [impeccable](https://github.com/pbakaus/impeccable) (Apache-2.0, derived from
|
|
4
|
+
> Anthropic's frontend-design skill). Rewritten 2026-11 from the `emil-design-eng`, `review-animations`,
|
|
5
|
+
> `animation-performance` and `remove-animations` skills in
|
|
6
|
+
> [`emilkowalski/skills`](https://github.com/emilkowalski/skills) (MIT © 2026 Emil Kowalski): the
|
|
7
|
+
> frequency gate, easing table, curves, physicality rules, escalation triggers and removal gate come from
|
|
8
|
+
> there. Read when adding, reviewing or removing animation or transitions.
|
|
5
9
|
|
|
6
|
-
|
|
10
|
+
Project motion tokens (`DESIGN.md`, CSS variables, Tailwind config) win over the values below. These are
|
|
11
|
+
the defaults when the project has none, and the bar when reviewing motion it already has.
|
|
7
12
|
|
|
8
|
-
|
|
9
|
-
doesn't answer one of those, cut it. Decorative motion is a visual-slop pattern.
|
|
13
|
+
## 1. Should it animate at all?
|
|
10
14
|
|
|
11
|
-
|
|
15
|
+
Decide this before choosing any curve. Frequency first:
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
| How often the user sees it | Decision |
|
|
18
|
+
|---|---|
|
|
19
|
+
| 100+ times a day (keyboard shortcuts, command palette, tab switches, in-app route changes, list rows) | No animation. Any duration reads as delay. |
|
|
20
|
+
| Tens of times a day (hover, frequent toggles, list expansion) | Remove, or reduce to near-imperceptible |
|
|
21
|
+
| Occasional (modals, drawers, toasts) | Standard motion |
|
|
22
|
+
| Rare or first-time (onboarding, success, empty state) | Room for delight |
|
|
15
23
|
|
|
16
|
-
-
|
|
17
|
-
(ease-out-expo) or `ease-out`.
|
|
18
|
-
- **Exit** (element leaves): ease-in — accelerate away. `cubic-bezier(0.4, 0, 1, 1)`.
|
|
19
|
-
- **Move/resize on screen:** ease-in-out (standard) `cubic-bezier(0.4, 0, 0.2, 1)`.
|
|
20
|
-
- Never linear for UI (only for continuous things like spinners/marquees).
|
|
24
|
+
**Keyboard-initiated actions never animate.** They repeat hundreds of times a day.
|
|
21
25
|
|
|
22
|
-
|
|
26
|
+
Then purpose. Valid purposes: spatial consistency (where did this come from / go), state indication,
|
|
27
|
+
explanation, feedback, preventing a jarring change. "It looks nice" on a frequently seen element is not a
|
|
28
|
+
purpose. Functional data (charts, tables, numbers) does not re-animate on every mount or refresh; it
|
|
29
|
+
animates only to show a real change in value.
|
|
23
30
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
- Large (page/route, full-screen sheets): 300–500ms.
|
|
27
|
-
- Over ~500ms feels sluggish; under ~80ms is imperceptible (just snap with no transition).
|
|
31
|
+
Deleting everything is also wrong: a toast that would otherwise teleport in, or a panel that needs to show
|
|
32
|
+
where it came from, earns its motion.
|
|
28
33
|
|
|
29
|
-
##
|
|
34
|
+
## 2. Easing
|
|
30
35
|
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
| Motion | Easing |
|
|
37
|
+
|---|---|
|
|
38
|
+
| Entering or exiting | `ease-out` (starts fast, feels responsive) |
|
|
39
|
+
| Moving or morphing on screen | `ease-in-out` |
|
|
40
|
+
| Hover, color change | `ease` |
|
|
41
|
+
| Constant motion (marquee, progress, spinner) | `linear` |
|
|
42
|
+
| Default | `ease-out` |
|
|
33
43
|
|
|
34
|
-
|
|
44
|
+
**Never `ease-in` on UI**, exits included. It starts slow and delays the exact moment the user is watching;
|
|
45
|
+
`ease-out` at 200ms feels faster than `ease-in` at 200ms.
|
|
35
46
|
|
|
36
|
-
|
|
37
|
-
Animating `width`, `height`, `top`, `left`, `margin` janks. Use `transform: translate/scale` instead.
|
|
47
|
+
The built-in keywords are weak. Default curves when the project has none:
|
|
38
48
|
|
|
39
|
-
|
|
49
|
+
```css
|
|
50
|
+
--ease-out: cubic-bezier(0.23, 1, 0.32, 1); /* UI enter/exit */
|
|
51
|
+
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); /* on-screen movement */
|
|
52
|
+
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); /* iOS-like drawer/sheet */
|
|
53
|
+
```
|
|
40
54
|
|
|
41
|
-
|
|
55
|
+
### Bounce and springs
|
|
42
56
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
57
|
+
No bounce, elastic or overshooting `cubic-bezier` (e.g. `cubic-bezier(0.68, -0.55, 0.27, 1.55)`) on UI
|
|
58
|
+
transitions — the `ui-slop-detect` gate flags these as `bounce-easing`.
|
|
59
|
+
|
|
60
|
+
**Springs are allowed only for gesture-driven motion:** drag with momentum, drag-to-dismiss sheets,
|
|
61
|
+
swipe, and anything the user may reverse mid-motion. Springs keep velocity when interrupted, which is what
|
|
62
|
+
a gesture needs. Keep bounce subtle:
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
{ type: "spring", duration: 0.5, bounce: 0.2 } // bounce 0.1–0.3
|
|
50
66
|
```
|
|
51
67
|
|
|
52
|
-
|
|
68
|
+
A spring on a button, dropdown, modal or toast is out of scope for this exception.
|
|
69
|
+
|
|
70
|
+
## 3. Duration
|
|
71
|
+
|
|
72
|
+
| Element | Duration |
|
|
73
|
+
|---|---|
|
|
74
|
+
| Button press feedback | 100–160ms |
|
|
75
|
+
| Tooltips, small popovers | 125–200ms |
|
|
76
|
+
| Dropdowns, selects | 150–250ms |
|
|
77
|
+
| Modals, drawers | 200–500ms |
|
|
78
|
+
| Marketing / explanatory | Can be longer |
|
|
79
|
+
|
|
80
|
+
**UI animations stay under 300ms** unless a reason is written down. Under ~80ms is imperceptible: snap
|
|
81
|
+
instead. After the first tooltip in a group opens, the next ones open instantly (no delay, no animation).
|
|
82
|
+
|
|
83
|
+
## 4. Physicality
|
|
84
|
+
|
|
85
|
+
- **Never `scale(0)`.** Nothing appears from nothing. Start from `scale(0.95)` (0.9–0.97) with `opacity: 0`.
|
|
86
|
+
- **Popovers, dropdowns and tooltips scale from their trigger**, not the center:
|
|
87
|
+
`transform-origin: var(--transform-origin)` (Base UI / Radix expose it). **Modals are exempt** — they
|
|
88
|
+
appear centered and keep `transform-origin: center`.
|
|
89
|
+
- **Press feedback:** `transform: scale(0.97)` on `:active` (0.95–0.98), 100–160ms `ease-out`, on every
|
|
90
|
+
pressable element. It works on touch, unlike hover.
|
|
91
|
+
- **`translateY(100%)`** moves an element by its own height, so drawers and toasts of unknown size need no
|
|
92
|
+
hardcoded pixels.
|
|
93
|
+
|
|
94
|
+
## 5. Interruptibility and timing
|
|
95
|
+
|
|
96
|
+
- **Transitions over keyframes** for anything triggered rapidly (toasts being added, toggles). A transition
|
|
97
|
+
retargets from its current state; a keyframe animation restarts from zero.
|
|
98
|
+
- Entry without JS: `@starting-style` on the transitioned element.
|
|
99
|
+
- **Asymmetric timing:** slow where the user is deciding (a hold-to-confirm, a destructive press), fast
|
|
100
|
+
where the system responds (the release, the result).
|
|
101
|
+
- **Stagger** group entrances 30–80ms per item, capped so the whole group finishes within ~300ms.
|
|
102
|
+
Stagger entrances, not exits, and never block interaction while it plays.
|
|
103
|
+
- An imperfect crossfade can be bridged with `filter: blur(2px)` during the transition; keep any animated
|
|
104
|
+
blur under 20px.
|
|
105
|
+
|
|
106
|
+
## 6. Performance
|
|
107
|
+
|
|
108
|
+
Every frame has **16.7ms at 60fps and 8ms at 120Hz** (most phones). Design against 8ms. A fast laptop hides
|
|
109
|
+
almost every problem below; say which findings were verified from code and which need a recording on a
|
|
110
|
+
mid-range device.
|
|
111
|
+
|
|
112
|
+
| Tier | Properties | Cost |
|
|
113
|
+
|---|---|---|
|
|
114
|
+
| Composite only | `transform`, `opacity` (and in current browsers `filter`, `clip-path`) | Cheapest |
|
|
115
|
+
| Paint + composite | `box-shadow`, `border-radius`, `color` | Redraw every frame |
|
|
116
|
+
| Layout + paint + composite | `width`, `height`, `padding`, `margin`, `top`, `left`, `border-width` | Most expensive |
|
|
117
|
+
|
|
118
|
+
- Animate `transform` and `opacity` by default. Swaps: `width`/`height` → `scale()`; `top`/`left`/`margin` →
|
|
119
|
+
`translate()`; animated `box-shadow` → `filter: drop-shadow()`.
|
|
120
|
+
- **Never `transition: all`.** List the exact properties; `all` animates whatever changes next, including
|
|
121
|
+
layout properties nobody meant to animate. (Tailwind: `transition-transform`, `transition-opacity`, not
|
|
122
|
+
`transition-all`.)
|
|
123
|
+
- Do not drive child transforms through a CSS variable set on a parent; it recalculates style for every
|
|
124
|
+
child. Set `transform` on the element itself.
|
|
125
|
+
- In React, a hand-rolled animation writes to `ref.current.style`, never `setState` every frame.
|
|
126
|
+
- Predetermined motion runs in CSS or WAAPI (off the main thread); JS/rAF for dynamic, gesture-driven
|
|
127
|
+
motion. Motion's `x`/`y`/`scale` shorthands are hardware-accelerated from `motion` 14.2; on older versions
|
|
128
|
+
animate the full `transform` string. Layout animations (`layout`, `layoutId`) always run on the main
|
|
129
|
+
thread.
|
|
130
|
+
- `will-change` is the last step, added only after a measured problem (a 1px shift at start/end, dropped
|
|
131
|
+
frames on a recording). Never on `*` or large containers.
|
|
132
|
+
- Name the cause before changing code. "Added `will-change`, seems smoother" is not a diagnosis.
|
|
133
|
+
|
|
134
|
+
## 7. Accessibility
|
|
135
|
+
|
|
136
|
+
- **`prefers-reduced-motion` means fewer and gentler, not zero.** Drop movement (translate, scale,
|
|
137
|
+
parallax, slide); keep the opacity and color changes that explain a state change.
|
|
138
|
+
|
|
139
|
+
```css
|
|
140
|
+
@media (prefers-reduced-motion: reduce) {
|
|
141
|
+
.drawer { transition: opacity 200ms ease; transform: none; }
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
```jsx
|
|
146
|
+
const reduce = useReducedMotion();
|
|
147
|
+
const closedX = reduce ? 0 : "-100%";
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
A global `animation-duration: 0.01ms !important` reset is an acceptable project-wide fallback, not the
|
|
151
|
+
design: state changes still have to be visible.
|
|
152
|
+
- **Gate hover motion** behind `@media (hover: hover) and (pointer: fine)`. Touch fires a fake hover on tap
|
|
153
|
+
and leaves it stuck (see `responsive.md`).
|
|
154
|
+
|
|
155
|
+
## 8. Removing motion
|
|
156
|
+
|
|
157
|
+
When the job is to cut motion (an over-animated product, a performance pass), walk each animation through
|
|
158
|
+
this gate and record the deciding branch:
|
|
159
|
+
|
|
160
|
+
1. Keyboard-triggered? → **delete**.
|
|
161
|
+
2. Seen 100+ times a day? → **delete**.
|
|
162
|
+
3. On functional data? → **delete**, unless it shows a real change in value.
|
|
163
|
+
4. No nameable purpose? → **keep** if it is a rare first-time moment, otherwise **delete**.
|
|
164
|
+
5. Purpose, seen tens of times a day? → **reduce**.
|
|
165
|
+
6. Purpose, rarer: over 300ms, or a slide/scale where a fade carries the same meaning? → **reduce**,
|
|
166
|
+
otherwise **keep**.
|
|
167
|
+
|
|
168
|
+
Marketing pages get more latitude (one hero entrance, explanation), but scroll-reveal on every section
|
|
169
|
+
still fails.
|
|
170
|
+
|
|
171
|
+
| Today | Reduced to |
|
|
172
|
+
|---|---|
|
|
173
|
+
| Hover with scale, lift or shadow growth | Color/opacity only, ≤150ms, `ease` |
|
|
174
|
+
| Slide or scale entrance on a frequent surface | Opacity only, 150ms, `ease-out` |
|
|
175
|
+
| UI animation over 300ms | 200–250ms, same curve |
|
|
176
|
+
| Stagger over 80ms per item or 300ms total | 30ms per item, or fade the group as one |
|
|
177
|
+
| Infinite loop (pulse, shimmer, glow) on a persistent element | Run once, or keep the static end state |
|
|
178
|
+
|
|
179
|
+
Spinners and skeletons on work that resolves under 300ms are deleted too (see `state-matrix.md`).
|
|
180
|
+
|
|
181
|
+
**Removal hazards** — check before deleting, or the removal becomes a regression:
|
|
182
|
+
|
|
183
|
+
- **Hidden start states.** `initial={{ opacity: 0 }}`, a keyframe from `opacity: 0` with
|
|
184
|
+
`fill-mode: forwards`, a reveal class waiting for an observer. Remove the start state with the animation
|
|
185
|
+
or the element stays invisible.
|
|
186
|
+
- **Code waiting on the animation:** `onAnimationEnd`, `transitionend`, `onExitComplete`, `setTimeout`
|
|
187
|
+
sized to a duration.
|
|
188
|
+
- **Exit animations delaying unmount** (`AnimatePresence`): move what ran on exit-complete to the close.
|
|
189
|
+
- **Layout animations masking a jump** (`layout`, FLIP): if the jump appears, keep the animation and report
|
|
190
|
+
the layout shift.
|
|
191
|
+
- **Focus moved in an animation callback** must move immediately.
|
|
192
|
+
- **Tests and selectors** that wait for animation classes or durations.
|
|
193
|
+
|
|
194
|
+
Then delete what the removal orphaned (unused `@keyframes`, `motion.div` with no motion props left, empty
|
|
195
|
+
`AnimatePresence`, unused variants and imports). Propose — never perform — removing a motion library
|
|
196
|
+
dependency.
|
|
197
|
+
|
|
198
|
+
## 9. Review escalation triggers
|
|
199
|
+
|
|
200
|
+
`/dw-review` treats each of these in a UI diff as a candidate finding (still subject to `dw-review-rigor`
|
|
201
|
+
refutation). Default severity **medium**; **high** when the motion runs on a keyboard-triggered or
|
|
202
|
+
100+/day action, or when it blocks interaction.
|
|
203
|
+
|
|
204
|
+
- `transition: all` / Tailwind `transition-all`.
|
|
205
|
+
- `scale(0)` as a start state.
|
|
206
|
+
- `ease-in` on any UI enter, exit or interaction.
|
|
207
|
+
- Animation on a keyboard shortcut, command-palette toggle or other 100+/day action.
|
|
208
|
+
- UI duration over 300ms with no stated reason.
|
|
209
|
+
- `transform-origin: center` on a trigger-anchored popover, dropdown or tooltip.
|
|
210
|
+
- Keyframes on toasts, toggles or anything triggered rapidly.
|
|
211
|
+
- Animating `width`, `height`, `margin`, `padding`, `top` or `left` when a transform does the same.
|
|
212
|
+
- A CSS variable on a parent driving child transforms.
|
|
213
|
+
- Movement with no `prefers-reduced-motion` handling.
|
|
214
|
+
- `:hover` motion not gated by `(hover: hover) and (pointer: fine)`.
|
|
215
|
+
- Bounce/overshoot easing, or a spring outside gesture-driven motion.
|
|
216
|
+
- Symmetric timing on a press-and-hold or destructive-confirm interaction.
|
|
217
|
+
|
|
218
|
+
Fix preference, earliest first: delete → reduce → fix easing → fix origin → make interruptible → move to
|
|
219
|
+
composite-only properties → asymmetric timing → polish → accessibility and cohesion.
|
|
220
|
+
|
|
221
|
+
When feel cannot be judged from code (hover feel, perceived speed, gesture physics), say so and ask for a
|
|
222
|
+
slow-motion pass (DevTools animation panel at 2–5× duration) or a real device, instead of guessing.
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# Responsive — mobile-first, container queries, fluid space
|
|
2
2
|
|
|
3
3
|
> Patterns adapted from [impeccable](https://github.com/pbakaus/impeccable) (Apache-2.0, derived from
|
|
4
|
-
> Anthropic's frontend-design skill).
|
|
4
|
+
> Anthropic's frontend-design skill). The "Mobile platform fixes" section is adapted from the
|
|
5
|
+
> `mobile-native` skill in [`emilkowalski/skills`](https://github.com/emilkowalski/skills)
|
|
6
|
+
> (MIT © 2026 Emil Kowalski). Read when a surface must work across devices.
|
|
5
7
|
|
|
6
8
|
## Mobile-first
|
|
7
9
|
|
|
@@ -43,10 +45,50 @@ Keep body text fixed (≥16px); fluid body harms readability.
|
|
|
43
45
|
- Touch targets ≥ 24×24 CSS px (≥ 44×44 recommended on mobile). Spacing between targets matters as much
|
|
44
46
|
as size.
|
|
45
47
|
- Don't hide primary actions behind hover — hover doesn't exist on touch. Provide a tap-visible path.
|
|
46
|
-
- Respect safe-area insets on mobile (`env(safe-area-inset-*)`) for notches/home indicators
|
|
48
|
+
- Respect safe-area insets on mobile (`env(safe-area-inset-*)`) for notches/home indicators — this needs
|
|
49
|
+
`viewport-fit=cover` in the viewport meta, or every inset is `0px` (see below).
|
|
47
50
|
|
|
48
51
|
## Layout reflow
|
|
49
52
|
|
|
50
53
|
State explicitly how elements reorganize between breakpoints: stack vs. side-by-side, what collapses into
|
|
51
54
|
a menu, what hides (and whether hidden content is still reachable). "Hidden on mobile with no alternative"
|
|
52
55
|
is a bug, not a layout decision.
|
|
56
|
+
|
|
57
|
+
## Mobile platform fixes
|
|
58
|
+
|
|
59
|
+
Most "the app feels janky on mobile" reports are not animation problems: they are a tap delay, a gray
|
|
60
|
+
flash on tap, or a hover state that will not let go. **None of these reproduce in desktop device
|
|
61
|
+
emulation.** Gate by capability with media queries, never by user-agent sniffing or screen width — touch
|
|
62
|
+
and mouse coexist (iPads with trackpads, touchscreen laptops). Apply each fix where its reason applies, not
|
|
63
|
+
globally out of habit.
|
|
64
|
+
|
|
65
|
+
| Symptom | Cause | Fix |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| Hover state stuck after tap | Touch fakes `:hover` on the first tap and keeps it | Put `:hover` rules inside `@media (hover: hover) and (pointer: fine)` (Tailwind v4 `hover:` already does; v3 needs `future.hoverOnlyWhenSupported`) and give touch an `:active` state |
|
|
68
|
+
| Gray/blue flash on tap | Browser tap highlight | `html { -webkit-tap-highlight-color: transparent; }`, then make sure every tappable element has its own `:active` feedback |
|
|
69
|
+
| Bottom-pinned UI hidden under the URL bar | `100vh` is the largest viewport | `100dvh` for app shells, drawers and bottom-pinned UI; `100svh` for heroes (stable, no shift on scroll) |
|
|
70
|
+
| Page zooms into an input and stays zoomed | iOS zooms on inputs under 16px | `font-size: 16px` on `input, textarea, select` (scope it with `@media (pointer: coarse)` if desktop needs smaller). **Never** `user-scalable=no` or `maximum-scale=1` — that is an accessibility failure (`accessibility-floor.md` §11) |
|
|
71
|
+
| Tap feels laggy | Double-tap-zoom wait; feedback on release | `touch-action: manipulation` on buttons, links, `[role="button"]`; press feedback on `:active` (100–160ms `ease-out`, see `motion.md`), `pointerdown` not `click` when JS must react |
|
|
72
|
+
| Pull-to-refresh or rubber band hijacks an app | Scroll chaining to the page | `overscroll-behavior: none` on `html, body` in apps (not documents); `contain` on inner scrollers. Never a `touchmove` + `preventDefault()` listener |
|
|
73
|
+
| Content cut by the notch / home indicator | Safe area | `viewport-fit=cover` in the viewport meta + `padding-top: env(safe-area-inset-top)` / `padding-bottom: env(safe-area-inset-bottom, 0px)` on fixed headers, tab bars, toasts and sheets |
|
|
74
|
+
| Long-press selects a button label | Text selection on controls | `user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;` on controls only — never on `body`; users copy addresses and order numbers |
|
|
75
|
+
| Carousel drags the page vertically | Ambiguous gesture axis | `touch-action: pan-y` on a horizontal carousel, `none` only on surfaces that own every axis; prefer native `scroll-snap-type: x mandatory` |
|
|
76
|
+
| Status bar color wrong in one scheme | Single `theme-color` | One `<meta name="theme-color" media="(prefers-color-scheme: …)">` per scheme, matching the top of the page, plus `color-scheme: light dark` |
|
|
77
|
+
|
|
78
|
+
Inputs also set the right keyboard: `type="email"` / `type="tel"`, `inputmode="numeric"` for codes and
|
|
79
|
+
`"decimal"` for amounts, `autocapitalize="none"` on usernames and codes, `enterkeyhint` naming what Return
|
|
80
|
+
does.
|
|
81
|
+
|
|
82
|
+
Baseline for a mobile-facing app:
|
|
83
|
+
|
|
84
|
+
```html
|
|
85
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`interactive-widget=resizes-content` makes the Android software keyboard shrink the layout viewport, so
|
|
89
|
+
`100dvh` and bottom-pinned inputs react to it the way they do on iOS.
|
|
90
|
+
|
|
91
|
+
**Real hardware is the bar.** Sticky hover, tap delay, URL-bar height, input zoom, overscroll, safe areas
|
|
92
|
+
and the software keyboard only show on a device: dev server on `0.0.0.0`, open it by LAN IP, inspect with
|
|
93
|
+
Safari Web Inspector or `chrome://inspect`. When no device is available, report which fixes were verified
|
|
94
|
+
from code and which still need a phone — never declare them fixed from emulation.
|