uxskill 2.0.0a1__py3-none-any.whl
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.
- data/anti-patterns.json +755 -0
- data/brands/_index.json +371 -0
- data/brands/airbnb.json +44 -0
- data/brands/airtable.json +47 -0
- data/brands/apple.json +50 -0
- data/brands/binance.json +48 -0
- data/brands/bmw-m.json +47 -0
- data/brands/bmw.json +48 -0
- data/brands/bugatti.json +44 -0
- data/brands/cal.json +49 -0
- data/brands/claude.json +51 -0
- data/brands/clay.json +48 -0
- data/brands/clickhouse.json +48 -0
- data/brands/cohere.json +49 -0
- data/brands/coinbase.json +49 -0
- data/brands/composio.json +50 -0
- data/brands/cursor.json +51 -0
- data/brands/elevenlabs.json +50 -0
- data/brands/expo.json +51 -0
- data/brands/ferrari.json +50 -0
- data/brands/figma.json +49 -0
- data/brands/framer.json +50 -0
- data/brands/hashicorp.json +49 -0
- data/brands/hp.json +49 -0
- data/brands/ibm.json +48 -0
- data/brands/intercom.json +50 -0
- data/brands/kraken.json +38 -0
- data/brands/lamborghini.json +39 -0
- data/brands/linear.app.json +51 -0
- data/brands/lovable.json +35 -0
- data/brands/mastercard.json +36 -0
- data/brands/meta.json +51 -0
- data/brands/minimax.json +51 -0
- data/brands/mintlify.json +51 -0
- data/brands/miro.json +51 -0
- data/brands/mistral.ai.json +51 -0
- data/brands/mongodb.json +50 -0
- data/brands/nike.json +47 -0
- data/brands/notion.json +51 -0
- data/brands/nvidia.json +46 -0
- data/brands/ollama.json +48 -0
- data/brands/opencode.ai.json +45 -0
- data/brands/pinterest.json +47 -0
- data/brands/playstation.json +47 -0
- data/brands/posthog.json +49 -0
- data/brands/raycast.json +49 -0
- data/brands/renault.json +47 -0
- data/brands/replicate.json +49 -0
- data/brands/resend.json +50 -0
- data/brands/revolut.json +48 -0
- data/brands/runwayml.json +48 -0
- data/brands/sanity.json +50 -0
- data/brands/sentry.json +49 -0
- data/brands/shopify.json +49 -0
- data/brands/slack.json +49 -0
- data/brands/spacex.json +45 -0
- data/brands/spotify.json +40 -0
- data/brands/starbucks.json +35 -0
- data/brands/stripe.json +49 -0
- data/brands/supabase.json +50 -0
- data/brands/superhuman.json +48 -0
- data/brands/tesla.json +41 -0
- data/brands/theverge.json +36 -0
- data/brands/together.ai.json +47 -0
- data/brands/uber.json +48 -0
- data/brands/vercel.json +52 -0
- data/brands/vodafone.json +48 -0
- data/brands/voltagent.json +48 -0
- data/brands/warp.json +50 -0
- data/brands/webflow.json +48 -0
- data/brands/wired.json +44 -0
- data/brands/wise.json +48 -0
- data/brands/x.ai.json +46 -0
- data/brands/zapier.json +46 -0
- data/chart-types.json +536 -0
- data/components.json +8020 -0
- data/industries.json +2354 -0
- data/motion-presets.json +1103 -0
- data/palettes.json +6876 -0
- data/styles.json +4310 -0
- data/tech-stacks.json +336 -0
- data/type-pairs.json +2041 -0
- data/ux-guidelines.json +1579 -0
- engine/__init__.py +7 -0
- engine/cli/__init__.py +1 -0
- engine/cli/main.py +222 -0
- engine/data_loader.py +76 -0
- engine/discovery/__init__.py +19 -0
- engine/discovery/core.py +113 -0
- engine/generator/__init__.py +3 -0
- engine/generator/core.py +127 -0
- engine/installer/__init__.py +8 -0
- engine/installer/core.py +207 -0
- engine/integration.py +187 -0
- engine/linter/__init__.py +3 -0
- engine/linter/core.py +175 -0
- engine/recommender/__init__.py +3 -0
- engine/recommender/core.py +227 -0
- uxskill-2.0.0a1.dist-info/METADATA +267 -0
- uxskill-2.0.0a1.dist-info/RECORD +104 -0
- uxskill-2.0.0a1.dist-info/WHEEL +5 -0
- uxskill-2.0.0a1.dist-info/entry_points.txt +3 -0
- uxskill-2.0.0a1.dist-info/licenses/LICENSE +21 -0
- uxskill-2.0.0a1.dist-info/top_level.txt +1 -0
data/anti-patterns.json
ADDED
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_meta": {
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"entries": 35,
|
|
5
|
+
"last_updated": "2026-05-27",
|
|
6
|
+
"source": "ux-skill v2 - Python pivot (ported from bin/ux-lint.sh)",
|
|
7
|
+
"schema_version": "1.0"
|
|
8
|
+
},
|
|
9
|
+
"entries": [
|
|
10
|
+
{
|
|
11
|
+
"id": "inter-as-display",
|
|
12
|
+
"name": "Inter used as display font",
|
|
13
|
+
"severity": "high",
|
|
14
|
+
"category": "Typography",
|
|
15
|
+
"detection": {
|
|
16
|
+
"type": "regex",
|
|
17
|
+
"pattern": "font-family:\\s*['\"]?Inter['\"]?[^;}]*[;}][^{]*(?:font-size:\\s*([4-9]\\d|\\d{3,})px|\\btext-(5xl|6xl|7xl|8xl|9xl)\\b)",
|
|
18
|
+
"flags": "im",
|
|
19
|
+
"scope": [
|
|
20
|
+
"css",
|
|
21
|
+
"scss",
|
|
22
|
+
"tsx",
|
|
23
|
+
"jsx",
|
|
24
|
+
"vue",
|
|
25
|
+
"html"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
"why": "Inter is a body font tuned for screen legibility at small sizes; deployed as display it reads as the default startup-landing fingerprint.",
|
|
29
|
+
"fix": "Pair Inter (body) with a distinctive display face: Geist, Satoshi, Cabinet Grotesk, General Sans, Outfit, or a brand-specific variable sans."
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"id": "purple-to-blue-gradient",
|
|
33
|
+
"name": "Default purple-to-blue AI gradient",
|
|
34
|
+
"severity": "high",
|
|
35
|
+
"category": "Color",
|
|
36
|
+
"detection": {
|
|
37
|
+
"type": "regex",
|
|
38
|
+
"pattern": "bg-gradient-to-[a-z]+\\s+from-(purple|violet|fuchsia|indigo)-(400|500|600)\\s+(?:via-[a-z]+-[0-9]+\\s+)?to-(blue|sky|cyan)-(400|500|600)|linear-gradient\\([^)]*#(7c3aed|8b5cf6|a855f7|6366f1)[^)]*#(3b82f6|2563eb|0ea5e9)[^)]*\\)",
|
|
39
|
+
"flags": "i",
|
|
40
|
+
"scope": [
|
|
41
|
+
"css",
|
|
42
|
+
"scss",
|
|
43
|
+
"tsx",
|
|
44
|
+
"jsx",
|
|
45
|
+
"vue",
|
|
46
|
+
"html",
|
|
47
|
+
"blade",
|
|
48
|
+
"astro"
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
"why": "Purple-to-blue gradient on white is the strongest visual fingerprint of unconstrained model output and reads as template-marketplace AI slop.",
|
|
52
|
+
"fix": "Use a single restrained accent (Emerald, Electric Blue, Deep Rose, Amber) against neutrals; keep gradient hue spread under 60 degrees if used at all."
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"id": "three-equal-card-grid",
|
|
56
|
+
"name": "Three equal cards in a row",
|
|
57
|
+
"severity": "high",
|
|
58
|
+
"category": "Layout",
|
|
59
|
+
"detection": {
|
|
60
|
+
"type": "regex",
|
|
61
|
+
"pattern": "(?:grid-cols-3|grid-template-columns:\\s*repeat\\(\\s*3\\s*,\\s*(?:1fr|minmax))[^>]*>(?:\\s*<[^/][^>]*class=\"[^\"]*(?:card|feature)[^\"]*\"[^>]*>[\\s\\S]*?</[^>]+>\\s*){3}",
|
|
62
|
+
"flags": "im",
|
|
63
|
+
"scope": [
|
|
64
|
+
"tsx",
|
|
65
|
+
"jsx",
|
|
66
|
+
"vue",
|
|
67
|
+
"html",
|
|
68
|
+
"blade",
|
|
69
|
+
"astro"
|
|
70
|
+
]
|
|
71
|
+
},
|
|
72
|
+
"why": "Three equal cards with three icons and three short paragraphs is the safest default the generator reaches for and the strongest layout fingerprint in AI-generated marketing surfaces.",
|
|
73
|
+
"fix": "Use asymmetric layouts: bento grids, 2-and-1 splits, 4 with one spanning width. Vary card size by content priority."
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"id": "fake-name-john-doe",
|
|
77
|
+
"name": "Generic placeholder names",
|
|
78
|
+
"severity": "medium",
|
|
79
|
+
"category": "Content",
|
|
80
|
+
"detection": {
|
|
81
|
+
"type": "regex",
|
|
82
|
+
"pattern": "\\b(?:John\\s+Doe|Jane\\s+Doe|John\\s+Smith|Jane\\s+Smith|Sarah\\s+Chan|Test\\s+User|Demo\\s+User|Foo\\s+Bar)\\b|\\b(?:john\\.doe|jane\\.doe|test\\.user)@(?:example|test|demo)\\.com\\b",
|
|
83
|
+
"flags": "i",
|
|
84
|
+
"scope": [
|
|
85
|
+
"tsx",
|
|
86
|
+
"jsx",
|
|
87
|
+
"ts",
|
|
88
|
+
"js",
|
|
89
|
+
"vue",
|
|
90
|
+
"html",
|
|
91
|
+
"blade",
|
|
92
|
+
"astro",
|
|
93
|
+
"md"
|
|
94
|
+
]
|
|
95
|
+
},
|
|
96
|
+
"why": "John/Jane Doe and their cousins signal that nobody thought about who would actually use the product - immediate AI-generated-tutorial vibe.",
|
|
97
|
+
"fix": "Use plausible names that fit the target market: Maya Iqbal, Adam Levin, Wen Zhang, Layla Haddad. Match region for regional products."
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"id": "lorem-ipsum-leak",
|
|
101
|
+
"name": "Lorem ipsum in shipping code",
|
|
102
|
+
"severity": "high",
|
|
103
|
+
"category": "Content",
|
|
104
|
+
"detection": {
|
|
105
|
+
"type": "regex",
|
|
106
|
+
"pattern": "\\b(?:Lorem\\s+ipsum|lorem\\s+ipsum\\s+dolor|consectetur\\s+adipiscing|sed\\s+do\\s+eiusmod)\\b",
|
|
107
|
+
"flags": "i",
|
|
108
|
+
"scope": [
|
|
109
|
+
"tsx",
|
|
110
|
+
"jsx",
|
|
111
|
+
"ts",
|
|
112
|
+
"js",
|
|
113
|
+
"vue",
|
|
114
|
+
"html",
|
|
115
|
+
"blade",
|
|
116
|
+
"astro",
|
|
117
|
+
"md",
|
|
118
|
+
"css",
|
|
119
|
+
"scss"
|
|
120
|
+
]
|
|
121
|
+
},
|
|
122
|
+
"why": "Lorem ipsum in production source code is unshipped placeholder content - the most obvious draft-state leak.",
|
|
123
|
+
"fix": "Write real copy that makes a specific claim about the product. If you can't write it yet, mark the block with TODO and a content owner."
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"id": "emoji-in-ui",
|
|
127
|
+
"name": "Emoji used as UI element",
|
|
128
|
+
"severity": "high",
|
|
129
|
+
"category": "Content",
|
|
130
|
+
"detection": {
|
|
131
|
+
"type": "regex",
|
|
132
|
+
"pattern": "<(?:button|a|h[1-6]|span|div|p|li)[^>]*>[^<]*(?:[\\U0001F300-\\U0001F9FF]|[\\U0001F600-\\U0001F64F]|[\\U0001F680-\\U0001F6FF]|[\\u2600-\\u26FF]|[\\u2700-\\u27BF])",
|
|
133
|
+
"flags": "u",
|
|
134
|
+
"scope": [
|
|
135
|
+
"tsx",
|
|
136
|
+
"jsx",
|
|
137
|
+
"vue",
|
|
138
|
+
"html",
|
|
139
|
+
"blade",
|
|
140
|
+
"astro"
|
|
141
|
+
]
|
|
142
|
+
},
|
|
143
|
+
"why": "Emojis render inconsistently across platforms, ignore brand color, and signal informality where SVG icons signal craft.",
|
|
144
|
+
"fix": "Inline SVG from Lucide, Feather, Phosphor, or Heroicons at 1.5-2px stroke with currentColor. Whitelist U+2713 check and U+2318 command only."
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"id": "centered-everything-hero",
|
|
148
|
+
"name": "Centered hero composition",
|
|
149
|
+
"severity": "medium",
|
|
150
|
+
"category": "Layout",
|
|
151
|
+
"detection": {
|
|
152
|
+
"type": "regex",
|
|
153
|
+
"pattern": "<(?:section|div|header)[^>]*class=\"[^\"]*\\b(?:hero|banner)\\b[^\"]*\"[^>]*>[\\s\\S]{0,800}?<h1[^>]*class=\"[^\"]*\\b(?:text-center|items-center\\s+justify-center)\\b[^\"]*\"|<(?:section|div)[^>]*class=\"[^\"]*\\b(?:hero|banner)\\b[^\"]*\\bflex\\b[^\"]*\\bitems-center\\b[^\"]*\\bjustify-center\\b[^\"]*\\btext-center\\b",
|
|
154
|
+
"flags": "i",
|
|
155
|
+
"scope": [
|
|
156
|
+
"tsx",
|
|
157
|
+
"jsx",
|
|
158
|
+
"vue",
|
|
159
|
+
"html",
|
|
160
|
+
"blade",
|
|
161
|
+
"astro"
|
|
162
|
+
]
|
|
163
|
+
},
|
|
164
|
+
"why": "Centered headline + centered subtitle + centered button stack is the laziest hero composition and signals the generator picked it when it couldn't find a better layout.",
|
|
165
|
+
"fix": "Use left-aligned hero with editorial 7-5 or 8-4 grid split; let the imagery earn its own column."
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
"id": "gradient-text-rainbow",
|
|
169
|
+
"name": "Multi-stop gradient text",
|
|
170
|
+
"severity": "medium",
|
|
171
|
+
"category": "Color",
|
|
172
|
+
"detection": {
|
|
173
|
+
"type": "regex",
|
|
174
|
+
"pattern": "(?:bg-clip-text|background-clip:\\s*text)[^\"';]*(?:bg-gradient-to-[a-z]+\\s+from-[a-z]+-[0-9]+\\s+via-[a-z]+-[0-9]+\\s+to-[a-z]+-[0-9]+|linear-gradient\\([^)]*,[^)]*,[^)]*,[^)]*\\))",
|
|
175
|
+
"flags": "i",
|
|
176
|
+
"scope": [
|
|
177
|
+
"css",
|
|
178
|
+
"scss",
|
|
179
|
+
"tsx",
|
|
180
|
+
"jsx",
|
|
181
|
+
"vue",
|
|
182
|
+
"html",
|
|
183
|
+
"blade",
|
|
184
|
+
"astro"
|
|
185
|
+
]
|
|
186
|
+
},
|
|
187
|
+
"why": "Three-stop rainbow text on a hero word is the strongest 'AI hero' tell after the purple-to-blue gradient.",
|
|
188
|
+
"fix": "Use a single solid color for headlines. If gradient text is essential, two stops in analogous hues at modest saturation."
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
"id": "card-glow-purple-shadow",
|
|
192
|
+
"name": "Purple glow shadow on cards",
|
|
193
|
+
"severity": "medium",
|
|
194
|
+
"category": "Color",
|
|
195
|
+
"detection": {
|
|
196
|
+
"type": "regex",
|
|
197
|
+
"pattern": "box-shadow:[^;]*rgba?\\(\\s*(?:12[0-9]|13[0-9]|14[0-9])\\s*,\\s*(?:5[0-9]|6[0-9]|7[0-9])\\s*,\\s*(?:2[0-9]\\d|2[3-5]\\d)|shadow-(?:purple|violet|fuchsia|indigo)-(?:400|500|600)",
|
|
198
|
+
"flags": "i",
|
|
199
|
+
"scope": [
|
|
200
|
+
"css",
|
|
201
|
+
"scss",
|
|
202
|
+
"tsx",
|
|
203
|
+
"jsx",
|
|
204
|
+
"vue",
|
|
205
|
+
"html"
|
|
206
|
+
]
|
|
207
|
+
},
|
|
208
|
+
"why": "Purple-tinted shadows on cards are the second-strongest 'AI premium' fingerprint after the gradient itself.",
|
|
209
|
+
"fix": "Use neutral diffusion shadow: shadow-[0_20px_40px_-15px_rgba(0,0,0,0.08)]. Color comes from the surface, not the shadow."
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
"id": "timing-300ms-default",
|
|
213
|
+
"name": "Default 300ms transition timing",
|
|
214
|
+
"severity": "low",
|
|
215
|
+
"category": "Motion",
|
|
216
|
+
"detection": {
|
|
217
|
+
"type": "regex",
|
|
218
|
+
"pattern": "(?:transition(?:-duration)?|animation-duration):\\s*300ms\\b|duration-300\\b|\\bduration:\\s*0\\.3s\\b",
|
|
219
|
+
"flags": "i",
|
|
220
|
+
"scope": [
|
|
221
|
+
"css",
|
|
222
|
+
"scss",
|
|
223
|
+
"tsx",
|
|
224
|
+
"jsx",
|
|
225
|
+
"vue",
|
|
226
|
+
"html"
|
|
227
|
+
]
|
|
228
|
+
},
|
|
229
|
+
"why": "300ms is the editor default - using it everywhere signals zero motion intent and reads as laziness to anyone who tunes animation curves.",
|
|
230
|
+
"fix": "Micro-interactions 150-220ms, complex transitions 250-400ms, exit 60-70% of entry. Pick durations that match the feel, not the default."
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
"id": "cubic-bezier-material-only",
|
|
234
|
+
"name": "Material default easing everywhere",
|
|
235
|
+
"severity": "low",
|
|
236
|
+
"category": "Motion",
|
|
237
|
+
"detection": {
|
|
238
|
+
"type": "regex",
|
|
239
|
+
"pattern": "cubic-bezier\\(\\s*0?\\.4\\s*,\\s*0\\s*,\\s*0?\\.2\\s*,\\s*1\\s*\\)",
|
|
240
|
+
"flags": "i",
|
|
241
|
+
"scope": [
|
|
242
|
+
"css",
|
|
243
|
+
"scss",
|
|
244
|
+
"tsx",
|
|
245
|
+
"jsx",
|
|
246
|
+
"vue",
|
|
247
|
+
"html"
|
|
248
|
+
]
|
|
249
|
+
},
|
|
250
|
+
"why": "cubic-bezier(0.4, 0, 0.2, 1) is Material Design's default standard ease - using it as the only curve signals no taste decision was made.",
|
|
251
|
+
"fix": "Use cubic-bezier(0.16, 1, 0.3, 1) for premium exits, cubic-bezier(0.34, 1.56, 0.64, 1) for playful overshoot, or spring physics."
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
"id": "font-system-only",
|
|
255
|
+
"name": "System font stack with no chosen typeface",
|
|
256
|
+
"severity": "low",
|
|
257
|
+
"category": "Typography",
|
|
258
|
+
"detection": {
|
|
259
|
+
"type": "regex",
|
|
260
|
+
"pattern": "font-family:\\s*(?:-apple-system|BlinkMacSystemFont)(?:\\s*,\\s*(?:BlinkMacSystemFont|'?Segoe UI'?|Roboto|Oxygen|Ubuntu|Cantarell|'?Helvetica Neue'?|sans-serif|system-ui))*\\s*;",
|
|
261
|
+
"flags": "i",
|
|
262
|
+
"scope": [
|
|
263
|
+
"css",
|
|
264
|
+
"scss",
|
|
265
|
+
"tsx",
|
|
266
|
+
"jsx",
|
|
267
|
+
"vue",
|
|
268
|
+
"html"
|
|
269
|
+
]
|
|
270
|
+
},
|
|
271
|
+
"why": "Falling back to the system stack alone with no chosen typeface gives up the chance to have any typographic identity.",
|
|
272
|
+
"fix": "Pick a real face: Geist, Inter, Satoshi, IBM Plex Sans, Cabinet Grotesk, or the brand's variable. System stack is the absolute last fallback."
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
"id": "icon-emoji-stamp",
|
|
276
|
+
"name": "Emoji used as icon stamp",
|
|
277
|
+
"severity": "high",
|
|
278
|
+
"category": "Content",
|
|
279
|
+
"detection": {
|
|
280
|
+
"type": "regex",
|
|
281
|
+
"pattern": "class=\"[^\"]*(?:icon|stamp|badge|achievement|reward)[^\"]*\"[^>]*>\\s*(?:[\\U0001F300-\\U0001F9FF]|[\\u2600-\\u27BF]|[\\U0001F600-\\U0001F6FF])",
|
|
282
|
+
"flags": "u",
|
|
283
|
+
"scope": [
|
|
284
|
+
"tsx",
|
|
285
|
+
"jsx",
|
|
286
|
+
"vue",
|
|
287
|
+
"html",
|
|
288
|
+
"blade",
|
|
289
|
+
"astro"
|
|
290
|
+
]
|
|
291
|
+
},
|
|
292
|
+
"why": "Emoji where an SVG icon belongs breaks brand color, scales poorly, and renders differently on every OS.",
|
|
293
|
+
"fix": "Inline SVG icon with currentColor and a consistent stroke width across the surface."
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
"id": "pill-rounded-full-everywhere",
|
|
297
|
+
"name": "rounded-full applied to everything",
|
|
298
|
+
"severity": "low",
|
|
299
|
+
"category": "Layout",
|
|
300
|
+
"detection": {
|
|
301
|
+
"type": "regex",
|
|
302
|
+
"pattern": "<button[^>]*class=\"[^\"]*\\brounded-full\\b[^\"]*\"[^>]*>(?![^<]*(?:×|close|menu|search))|<(?:input|textarea)[^>]*class=\"[^\"]*\\brounded-full\\b",
|
|
303
|
+
"flags": "i",
|
|
304
|
+
"scope": [
|
|
305
|
+
"tsx",
|
|
306
|
+
"jsx",
|
|
307
|
+
"vue",
|
|
308
|
+
"html",
|
|
309
|
+
"blade",
|
|
310
|
+
"astro"
|
|
311
|
+
]
|
|
312
|
+
},
|
|
313
|
+
"why": "rounded-full on every button and input is the iOS-tutorial fingerprint - signals the radius decision was skipped entirely.",
|
|
314
|
+
"fix": "rounded-full for pills, avatars, icon buttons only. Use rounded-md or rounded-lg for primary buttons; rounded-xl or rounded-2xl for cards."
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
"id": "hero-text-arbitrary-90px",
|
|
318
|
+
"name": "Arbitrary hero font size",
|
|
319
|
+
"severity": "medium",
|
|
320
|
+
"category": "Typography",
|
|
321
|
+
"detection": {
|
|
322
|
+
"type": "regex",
|
|
323
|
+
"pattern": "text-\\[(?:9[0-9]|1[0-9]{2})px\\]|font-size:\\s*(?:9[0-9]|1[0-9]{2})px\\b",
|
|
324
|
+
"flags": "i",
|
|
325
|
+
"scope": [
|
|
326
|
+
"css",
|
|
327
|
+
"scss",
|
|
328
|
+
"tsx",
|
|
329
|
+
"jsx",
|
|
330
|
+
"vue",
|
|
331
|
+
"html"
|
|
332
|
+
]
|
|
333
|
+
},
|
|
334
|
+
"why": "Arbitrary px values like text-[90px] or text-[112px] bypass the type scale and signal the size was picked by eye, not by system.",
|
|
335
|
+
"fix": "Extend the Tailwind theme scale or use clamp() for fluid hero typography. text-6xl/text-7xl/text-8xl exist for a reason."
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
"id": "blur-bg-only-decoration",
|
|
339
|
+
"name": "Backdrop blur with no glass surface",
|
|
340
|
+
"severity": "low",
|
|
341
|
+
"category": "Visual",
|
|
342
|
+
"detection": {
|
|
343
|
+
"type": "regex",
|
|
344
|
+
"pattern": "(?:backdrop-blur(?:-(?:sm|md|lg|xl|2xl|3xl))?|backdrop-filter:\\s*blur\\([^)]+\\))(?![^\"';]*(?:bg-white/[0-9]|bg-black/[0-9]|bg-[a-z]+-[0-9]+/[0-9]|background:[^;]*rgba))",
|
|
345
|
+
"flags": "i",
|
|
346
|
+
"scope": [
|
|
347
|
+
"css",
|
|
348
|
+
"scss",
|
|
349
|
+
"tsx",
|
|
350
|
+
"jsx",
|
|
351
|
+
"vue",
|
|
352
|
+
"html"
|
|
353
|
+
]
|
|
354
|
+
},
|
|
355
|
+
"why": "Backdrop blur applied without a translucent surface behind it is GPU work for zero visual benefit - decoration, not design.",
|
|
356
|
+
"fix": "Pair backdrop-blur with a translucent fill: bg-white/70 or bg-zinc-950/60. Otherwise remove the blur entirely."
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
"id": "avatar-stack-overlapping",
|
|
360
|
+
"name": "Generic overlapping avatar stack",
|
|
361
|
+
"severity": "medium",
|
|
362
|
+
"category": "Layout",
|
|
363
|
+
"detection": {
|
|
364
|
+
"type": "regex",
|
|
365
|
+
"pattern": "class=\"[^\"]*-space-x-[0-9]+[^\"]*\"[^>]*>(?:\\s*<(?:img|div)[^>]*class=\"[^\"]*(?:avatar|rounded-full)[^\"]*\"[^>]*/?>\\s*){3,}",
|
|
366
|
+
"flags": "i",
|
|
367
|
+
"scope": [
|
|
368
|
+
"tsx",
|
|
369
|
+
"jsx",
|
|
370
|
+
"vue",
|
|
371
|
+
"html",
|
|
372
|
+
"blade",
|
|
373
|
+
"astro"
|
|
374
|
+
]
|
|
375
|
+
},
|
|
376
|
+
"why": "Three overlapping circular avatars in the nav as 'our team' or 'join 10k users' is a content-marketing template tell.",
|
|
377
|
+
"fix": "Use real customer logos, named testimonials with quotes, or specific signal (Used by 312 engineering teams at Series-A companies)."
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
"id": "testimonial-fake-five-stars",
|
|
381
|
+
"name": "Hardcoded five-star testimonial",
|
|
382
|
+
"severity": "high",
|
|
383
|
+
"category": "Content",
|
|
384
|
+
"detection": {
|
|
385
|
+
"type": "regex",
|
|
386
|
+
"pattern": "(?:\\u2605{5}|★{5}|⭐{5}|\\*{5})|<(?:span|div|p)[^>]*class=\"[^\"]*stars?[^\"]*\"[^>]*>\\s*(?:\\u2605|★){4,}",
|
|
387
|
+
"flags": "i",
|
|
388
|
+
"scope": [
|
|
389
|
+
"tsx",
|
|
390
|
+
"jsx",
|
|
391
|
+
"vue",
|
|
392
|
+
"html",
|
|
393
|
+
"blade",
|
|
394
|
+
"astro",
|
|
395
|
+
"md"
|
|
396
|
+
]
|
|
397
|
+
},
|
|
398
|
+
"why": "Five hardcoded stars next to a fake quote is the lowest-trust pattern on the web; users have learned to discount it on sight.",
|
|
399
|
+
"fix": "Show real, named, attributable testimonials with company affiliation and a specific outcome. No star count unless aggregated from a real review system."
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
"id": "cta-arrow-rightward-bouncing",
|
|
403
|
+
"name": "Bouncing arrow on CTA",
|
|
404
|
+
"severity": "medium",
|
|
405
|
+
"category": "Motion",
|
|
406
|
+
"detection": {
|
|
407
|
+
"type": "regex",
|
|
408
|
+
"pattern": "<(?:button|a)[^>]*>[^<]*(?:→|->|→|→)[^<]*</(?:button|a)>|class=\"[^\"]*\\banimate-bounce\\b[^\"]*\"[^>]*>\\s*(?:→|→|→|<svg)",
|
|
409
|
+
"flags": "i",
|
|
410
|
+
"scope": [
|
|
411
|
+
"tsx",
|
|
412
|
+
"jsx",
|
|
413
|
+
"vue",
|
|
414
|
+
"html",
|
|
415
|
+
"blade",
|
|
416
|
+
"astro"
|
|
417
|
+
]
|
|
418
|
+
},
|
|
419
|
+
"why": "Bouncing arrow on a CTA is the desperate-attention pattern that signals the copy itself isn't doing the work.",
|
|
420
|
+
"fix": "Let the CTA copy carry the action (Start a 14-day trial, Read the deployment guide). If you need an arrow, use a static SVG that nudges 2-4px on hover."
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
"id": "nav-equal-hamburger-desktop",
|
|
424
|
+
"name": "Hamburger menu on desktop",
|
|
425
|
+
"severity": "low",
|
|
426
|
+
"category": "Layout",
|
|
427
|
+
"detection": {
|
|
428
|
+
"type": "regex",
|
|
429
|
+
"pattern": "<(?:button|div)[^>]*(?:aria-label=\"(?:menu|navigation|hamburger)\"|class=\"[^\"]*\\b(?:hamburger|menu-toggle)\\b[^\"]*\")[^>]*>(?![\\s\\S]*?(?:md:hidden|lg:hidden|hidden\\s+md:|hidden\\s+lg:))",
|
|
430
|
+
"flags": "i",
|
|
431
|
+
"scope": [
|
|
432
|
+
"tsx",
|
|
433
|
+
"jsx",
|
|
434
|
+
"vue",
|
|
435
|
+
"html",
|
|
436
|
+
"blade",
|
|
437
|
+
"astro"
|
|
438
|
+
]
|
|
439
|
+
},
|
|
440
|
+
"why": "Hamburger on a wide viewport hides nav from users for no reason; signals the responsive breakpoint was skipped.",
|
|
441
|
+
"fix": "Show inline nav on md: and up; hide the hamburger above that breakpoint with md:hidden."
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
"id": "tailwind-color-named-vague",
|
|
445
|
+
"name": "Named Tailwind colors with no semantic token",
|
|
446
|
+
"severity": "low",
|
|
447
|
+
"category": "Color",
|
|
448
|
+
"detection": {
|
|
449
|
+
"type": "regex",
|
|
450
|
+
"pattern": "\\b(?:bg|text|border|ring|from|to|via)-(?:blue|red|green|yellow|purple|pink|indigo|orange)-(?:400|500|600)\\b",
|
|
451
|
+
"flags": "i",
|
|
452
|
+
"scope": [
|
|
453
|
+
"tsx",
|
|
454
|
+
"jsx",
|
|
455
|
+
"vue",
|
|
456
|
+
"html",
|
|
457
|
+
"blade",
|
|
458
|
+
"astro",
|
|
459
|
+
"css"
|
|
460
|
+
]
|
|
461
|
+
},
|
|
462
|
+
"why": "Raw bg-blue-500 / bg-purple-500 with no semantic token (primary, success, danger) signals the color system was never designed.",
|
|
463
|
+
"fix": "Define semantic tokens in tailwind.config or CSS custom properties: bg-primary, text-success, ring-accent. Map them once."
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
"id": "inline-style-attribute",
|
|
467
|
+
"name": "Inline style attribute",
|
|
468
|
+
"severity": "medium",
|
|
469
|
+
"category": "Quality",
|
|
470
|
+
"detection": {
|
|
471
|
+
"type": "regex",
|
|
472
|
+
"pattern": "<(?:div|span|p|section|article|h[1-6]|button|a|img)[^>]*\\sstyle=\"[^\"]+\"",
|
|
473
|
+
"flags": "i",
|
|
474
|
+
"scope": [
|
|
475
|
+
"tsx",
|
|
476
|
+
"jsx",
|
|
477
|
+
"vue",
|
|
478
|
+
"html",
|
|
479
|
+
"blade",
|
|
480
|
+
"astro"
|
|
481
|
+
]
|
|
482
|
+
},
|
|
483
|
+
"why": "Inline style= attributes bypass the design system, defeat caching, and signal the styling decision was made ad hoc.",
|
|
484
|
+
"fix": "Use Tailwind utilities, a CSS class, or a styled component. Reserve inline style for runtime-computed values (animated translate, dynamic CSS vars) only."
|
|
485
|
+
},
|
|
486
|
+
{
|
|
487
|
+
"id": "console-log-leak",
|
|
488
|
+
"name": "console.log in component code",
|
|
489
|
+
"severity": "high",
|
|
490
|
+
"category": "Quality",
|
|
491
|
+
"detection": {
|
|
492
|
+
"type": "regex",
|
|
493
|
+
"pattern": "console\\.(?:log|warn|debug|info)\\s*\\(",
|
|
494
|
+
"flags": "i",
|
|
495
|
+
"scope": [
|
|
496
|
+
"tsx",
|
|
497
|
+
"jsx",
|
|
498
|
+
"ts",
|
|
499
|
+
"js",
|
|
500
|
+
"vue",
|
|
501
|
+
"astro"
|
|
502
|
+
]
|
|
503
|
+
},
|
|
504
|
+
"why": "console.log shipped to production leaks state to anyone with DevTools open and signals the build didn't go through a real review.",
|
|
505
|
+
"fix": "Remove debug logs before shipping. If you need structured logging, use a logger module that's tree-shaken out of production builds."
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
"id": "todo-fixme-comment",
|
|
509
|
+
"name": "TODO or FIXME in shipping code",
|
|
510
|
+
"severity": "low",
|
|
511
|
+
"category": "Quality",
|
|
512
|
+
"detection": {
|
|
513
|
+
"type": "regex",
|
|
514
|
+
"pattern": "(?://|/\\*|<!--|\\{/\\*)\\s*(?:TODO|FIXME|XXX|HACK|WIP|REFACTOR)\\b",
|
|
515
|
+
"flags": "i",
|
|
516
|
+
"scope": [
|
|
517
|
+
"tsx",
|
|
518
|
+
"jsx",
|
|
519
|
+
"ts",
|
|
520
|
+
"js",
|
|
521
|
+
"vue",
|
|
522
|
+
"html",
|
|
523
|
+
"blade",
|
|
524
|
+
"astro",
|
|
525
|
+
"css",
|
|
526
|
+
"scss"
|
|
527
|
+
]
|
|
528
|
+
},
|
|
529
|
+
"why": "TODO/FIXME in shipping code is an unresolved promise to the reader and a draft-state leak.",
|
|
530
|
+
"fix": "Resolve the TODO before merge, or move it to the issue tracker with a specific owner and date."
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
"id": "any-type-leak",
|
|
534
|
+
"name": "TypeScript any type",
|
|
535
|
+
"severity": "medium",
|
|
536
|
+
"category": "Quality",
|
|
537
|
+
"detection": {
|
|
538
|
+
"type": "regex",
|
|
539
|
+
"pattern": ":\\s*any(?:\\s*[;,)=\\]>]|\\s*$)|<any[,>]|as\\s+any\\b",
|
|
540
|
+
"flags": "",
|
|
541
|
+
"scope": [
|
|
542
|
+
"ts",
|
|
543
|
+
"tsx"
|
|
544
|
+
]
|
|
545
|
+
},
|
|
546
|
+
"why": "any defeats the type system and signals the type was never figured out - the code is JavaScript pretending to be TypeScript.",
|
|
547
|
+
"fix": "Use unknown for genuinely unknown shapes and narrow with type guards. Use a specific interface or generic for everything else."
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
"id": "inline-svg-no-aria",
|
|
551
|
+
"name": "SVG without aria-label or aria-hidden",
|
|
552
|
+
"severity": "high",
|
|
553
|
+
"category": "A11y",
|
|
554
|
+
"detection": {
|
|
555
|
+
"type": "regex",
|
|
556
|
+
"pattern": "<svg(?![^>]*\\baria-label=)(?![^>]*\\baria-labelledby=)(?![^>]*\\baria-hidden=)(?![^>]*\\brole=\"presentation\")[^>]*>",
|
|
557
|
+
"flags": "i",
|
|
558
|
+
"scope": [
|
|
559
|
+
"tsx",
|
|
560
|
+
"jsx",
|
|
561
|
+
"vue",
|
|
562
|
+
"html",
|
|
563
|
+
"blade",
|
|
564
|
+
"astro"
|
|
565
|
+
]
|
|
566
|
+
},
|
|
567
|
+
"why": "SVG with no aria-label and no aria-hidden is announced by screen readers as 'graphic' with no context - failure of WCAG 1.1.1.",
|
|
568
|
+
"fix": "Decorative SVG gets aria-hidden=\"true\". Informative SVG gets aria-label=\"description\" or aria-labelledby pointing to a title element."
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
"id": "button-no-type",
|
|
572
|
+
"name": "Button missing type attribute",
|
|
573
|
+
"severity": "medium",
|
|
574
|
+
"category": "A11y",
|
|
575
|
+
"detection": {
|
|
576
|
+
"type": "regex",
|
|
577
|
+
"pattern": "<button(?![^>]*\\btype=)[^>]*>",
|
|
578
|
+
"flags": "i",
|
|
579
|
+
"scope": [
|
|
580
|
+
"tsx",
|
|
581
|
+
"jsx",
|
|
582
|
+
"vue",
|
|
583
|
+
"html",
|
|
584
|
+
"blade",
|
|
585
|
+
"astro"
|
|
586
|
+
]
|
|
587
|
+
},
|
|
588
|
+
"why": "A <button> inside a <form> without type=\"button\" defaults to type=\"submit\" and silently submits the form on every click.",
|
|
589
|
+
"fix": "Always set type=\"button\" or type=\"submit\" explicitly. The default is a footgun, not a feature."
|
|
590
|
+
},
|
|
591
|
+
{
|
|
592
|
+
"id": "img-no-alt",
|
|
593
|
+
"name": "Image missing alt attribute",
|
|
594
|
+
"severity": "high",
|
|
595
|
+
"category": "A11y",
|
|
596
|
+
"detection": {
|
|
597
|
+
"type": "regex",
|
|
598
|
+
"pattern": "<img(?![^>]*\\balt=)[^>]*/?>",
|
|
599
|
+
"flags": "i",
|
|
600
|
+
"scope": [
|
|
601
|
+
"tsx",
|
|
602
|
+
"jsx",
|
|
603
|
+
"vue",
|
|
604
|
+
"html",
|
|
605
|
+
"blade",
|
|
606
|
+
"astro"
|
|
607
|
+
]
|
|
608
|
+
},
|
|
609
|
+
"why": "Missing alt breaks screen readers, SEO image indexing, and the broken-image fallback all at once - WCAG 1.1.1 failure.",
|
|
610
|
+
"fix": "Every <img> gets alt. Decorative images get alt=\"\" (empty). Informative images get a description a screen reader user would need."
|
|
611
|
+
},
|
|
612
|
+
{
|
|
613
|
+
"id": "link-onclick-no-href",
|
|
614
|
+
"name": "Anchor with onClick but no href",
|
|
615
|
+
"severity": "high",
|
|
616
|
+
"category": "A11y",
|
|
617
|
+
"detection": {
|
|
618
|
+
"type": "regex",
|
|
619
|
+
"pattern": "<a(?![^>]*\\bhref=)[^>]*\\b(?:onClick|onclick)=",
|
|
620
|
+
"flags": "i",
|
|
621
|
+
"scope": [
|
|
622
|
+
"tsx",
|
|
623
|
+
"jsx",
|
|
624
|
+
"vue",
|
|
625
|
+
"html",
|
|
626
|
+
"blade",
|
|
627
|
+
"astro"
|
|
628
|
+
]
|
|
629
|
+
},
|
|
630
|
+
"why": "An <a> without href is not focusable by default and is invisible to assistive tech - it looks like a link but behaves like a button.",
|
|
631
|
+
"fix": "Use <button> for actions. Use <a href=\"...\"> for navigation. Never an anchor without href as a click target."
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
"id": "heading-skip-h1-h3",
|
|
635
|
+
"name": "Skipped heading level",
|
|
636
|
+
"severity": "medium",
|
|
637
|
+
"category": "A11y",
|
|
638
|
+
"detection": {
|
|
639
|
+
"type": "regex",
|
|
640
|
+
"pattern": "<h1[^>]*>[\\s\\S]*?</h1>[\\s\\S]*?<h3[^>]*>(?![\\s\\S]*?<h2)|<h2[^>]*>[\\s\\S]*?</h2>[\\s\\S]*?<h4[^>]*>(?![\\s\\S]*?<h3)|<h3[^>]*>(?:(?!<h[12]).)*?</h3>",
|
|
641
|
+
"flags": "is",
|
|
642
|
+
"scope": [
|
|
643
|
+
"tsx",
|
|
644
|
+
"jsx",
|
|
645
|
+
"vue",
|
|
646
|
+
"html",
|
|
647
|
+
"blade",
|
|
648
|
+
"astro"
|
|
649
|
+
]
|
|
650
|
+
},
|
|
651
|
+
"why": "Heading hierarchy gaps (h1 then h3, or h3 with no h1/h2 preceding) break the screen-reader landmark tree and the SEO outline.",
|
|
652
|
+
"fix": "h1 once per page; nest h2/h3/h4 in order. If you need a small visual heading without a logical level, use a styled <p> instead."
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
"id": "shadcn-default-everywhere",
|
|
656
|
+
"name": "Default shadcn token block unmodified",
|
|
657
|
+
"severity": "low",
|
|
658
|
+
"category": "Quality",
|
|
659
|
+
"detection": {
|
|
660
|
+
"type": "regex",
|
|
661
|
+
"pattern": "--background:\\s*0\\s+0%\\s+100%[\\s\\S]{0,200}--foreground:\\s*222\\.2\\s+84%\\s+4\\.9%|--primary:\\s*222\\.2\\s+47\\.4%\\s+11\\.2%|--radius:\\s*0\\.5rem;",
|
|
662
|
+
"flags": "i",
|
|
663
|
+
"scope": [
|
|
664
|
+
"css",
|
|
665
|
+
"scss"
|
|
666
|
+
]
|
|
667
|
+
},
|
|
668
|
+
"why": "The default shadcn HSL token block with --radius 0.5rem is recognizable in two seconds by anyone who has shipped one shadcn app.",
|
|
669
|
+
"fix": "Customize tokens at init: swap slate for zinc/stone, shift primary to a brand hue, set --radius to 0.625rem or 0.75rem."
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
"id": "gradient-mesh-purple-pink",
|
|
673
|
+
"name": "Purple-pink mesh gradient hero",
|
|
674
|
+
"severity": "medium",
|
|
675
|
+
"category": "Color",
|
|
676
|
+
"detection": {
|
|
677
|
+
"type": "regex",
|
|
678
|
+
"pattern": "radial-gradient\\([^)]*#(?:a855f7|c084fc|d946ef|e879f9|ec4899|f472b6)[^)]*\\)|conic-gradient\\([^)]*(?:purple|fuchsia|pink|violet)[^)]*\\)|bg-gradient-to-[a-z]+\\s+from-(?:purple|fuchsia|violet)-(?:400|500|600)\\s+to-(?:pink|rose|fuchsia)-(?:400|500|600)",
|
|
679
|
+
"flags": "i",
|
|
680
|
+
"scope": [
|
|
681
|
+
"css",
|
|
682
|
+
"scss",
|
|
683
|
+
"tsx",
|
|
684
|
+
"jsx",
|
|
685
|
+
"vue",
|
|
686
|
+
"html"
|
|
687
|
+
]
|
|
688
|
+
},
|
|
689
|
+
"why": "Purple-to-pink mesh gradient is the second-most fingerprinted AI-hero pattern after blue-to-purple - 'I generated a landing page' aesthetic.",
|
|
690
|
+
"fix": "Single accent against neutral canvas. If a hero needs depth, layer subtle noise or a single low-saturation analogous gradient under 60deg hue spread."
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
"id": "dark-text-on-dark-card",
|
|
694
|
+
"name": "Low-contrast text on card",
|
|
695
|
+
"severity": "high",
|
|
696
|
+
"category": "Color",
|
|
697
|
+
"detection": {
|
|
698
|
+
"type": "regex",
|
|
699
|
+
"pattern": "(?:bg-zinc-(?:[89]00|950)|bg-slate-(?:[89]00|950)|bg-gray-(?:[89]00|950)|bg-black|background:\\s*#0[0-9a-f]{5})[^\"';]*(?:text-zinc-(?:[5-7]00)|text-slate-(?:[5-7]00)|text-gray-(?:[5-7]00)|color:\\s*#[4-6][0-9a-f]{5})",
|
|
700
|
+
"flags": "i",
|
|
701
|
+
"scope": [
|
|
702
|
+
"css",
|
|
703
|
+
"scss",
|
|
704
|
+
"tsx",
|
|
705
|
+
"jsx",
|
|
706
|
+
"vue",
|
|
707
|
+
"html"
|
|
708
|
+
]
|
|
709
|
+
},
|
|
710
|
+
"why": "Dark text token (zinc-500/600/700) on a dark surface fails WCAG AA 4.5:1 contrast and signals the dark mode pairing was never tested.",
|
|
711
|
+
"fix": "Test contrast on both themes. On dark surfaces use text-zinc-100 to text-zinc-300; reserve 500-700 for tertiary captions on light surfaces."
|
|
712
|
+
},
|
|
713
|
+
{
|
|
714
|
+
"id": "arbitrary-z-index-9999",
|
|
715
|
+
"name": "Lazy z-index value",
|
|
716
|
+
"severity": "medium",
|
|
717
|
+
"category": "Quality",
|
|
718
|
+
"detection": {
|
|
719
|
+
"type": "regex",
|
|
720
|
+
"pattern": "z-index:\\s*(?:9999+|99999+|2147483647)\\b|\\bz-\\[(?:9999+|99999+)\\]",
|
|
721
|
+
"flags": "i",
|
|
722
|
+
"scope": [
|
|
723
|
+
"css",
|
|
724
|
+
"scss",
|
|
725
|
+
"tsx",
|
|
726
|
+
"jsx",
|
|
727
|
+
"vue",
|
|
728
|
+
"html"
|
|
729
|
+
]
|
|
730
|
+
},
|
|
731
|
+
"why": "z-index: 9999 is the 'I gave up on the stacking system' value - signals there's no z-scale token map and stacking bugs are coming.",
|
|
732
|
+
"fix": "Define a z-scale token set (z-base: 0, z-dropdown: 10, z-sticky: 20, z-modal: 50, z-toast: 60). Use the tokens, never a raw 9999."
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
"id": "infinite-scroll-no-pagination",
|
|
736
|
+
"name": "Infinite scroll without keyboard fallback",
|
|
737
|
+
"severity": "medium",
|
|
738
|
+
"category": "A11y",
|
|
739
|
+
"detection": {
|
|
740
|
+
"type": "regex",
|
|
741
|
+
"pattern": "(?:IntersectionObserver|useInfiniteScroll|InfiniteScroll|on(?:Scroll|EndReached))[\\s\\S]{0,500}?(?:loadMore|fetchMore|loadNext)(?![\\s\\S]{0,800}?<button[^>]*>(?:Load|Show|More))",
|
|
742
|
+
"flags": "i",
|
|
743
|
+
"scope": [
|
|
744
|
+
"tsx",
|
|
745
|
+
"jsx",
|
|
746
|
+
"ts",
|
|
747
|
+
"js",
|
|
748
|
+
"vue"
|
|
749
|
+
]
|
|
750
|
+
},
|
|
751
|
+
"why": "Pure infinite scroll with no Load more button locks out keyboard users and breaks back-button history - WCAG 2.4 failure plus a usability one.",
|
|
752
|
+
"fix": "Ship infinite scroll AND a visible Load more button. The button is the keyboard path; the observer is the convenience layer."
|
|
753
|
+
}
|
|
754
|
+
]
|
|
755
|
+
}
|