@minhduydev/mdpi 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1 -1
- package/dist/template/.pi/VERSION +1 -1
- package/dist/template/.pi/extensions/templates-injector.ts +34 -6
- package/dist/template/.pi/prompts/INDEX.md +3 -9
- package/dist/template/.pi/skills/INDEX.md +81 -19
- package/dist/template/.pi/skills/accessibility-audit/SKILL.md +8 -2
- package/dist/template/.pi/skills/baseline-ui/SKILL.md +211 -0
- package/dist/template/.pi/skills/dcp-hygiene/SKILL.md +1 -1
- package/dist/template/.pi/skills/design-taste-frontend/SKILL.md +53 -42
- package/dist/template/.pi/skills/fixing-accessibility/SKILL.md +509 -0
- package/dist/template/.pi/skills/frontend-design/SKILL.md +60 -47
- package/dist/template/.pi/skills/frontend-design/references/animation/motion-advanced.md +88 -15
- package/dist/template/.pi/skills/frontend-design/references/animation/motion-core.md +148 -13
- package/dist/template/.pi/skills/frontend-design/references/shadcn/setup.md +127 -20
- package/dist/template/.pi/skills/frontend-ui-engineering/SKILL.md +21 -27
- package/dist/template/.pi/skills/nextjs-app-router/SKILL.md +334 -0
- package/dist/template/.pi/skills/nextjs-cache/SKILL.md +262 -0
- package/dist/template/.pi/skills/oklch-color-workflow/SKILL.md +426 -0
- package/dist/template/.pi/skills/production-hardening/SKILL.md +652 -0
- package/dist/template/.pi/skills/react-best-practices/SKILL.md +79 -1
- package/dist/template/.pi/skills/react-compiler/SKILL.md +237 -0
- package/dist/template/.pi/skills/react-hook-form/SKILL.md +374 -0
- package/dist/template/.pi/skills/react-server-actions/SKILL.md +299 -0
- package/dist/template/.pi/skills/shadcn-ui/SKILL.md +404 -0
- package/dist/template/.pi/skills/tanstack-query/SKILL.md +330 -0
- package/dist/template/.pi/skills/ui-craft-principles/SKILL.md +564 -0
- package/dist/template/.pi/skills/ui-quality-audit/SKILL.md +329 -0
- package/dist/template/.pi/skills/v0/SKILL.md +264 -0
- package/dist/template/.pi/skills/zustand/SKILL.md +333 -0
- package/dist/template/.pi/templates/DESIGN.md +76 -0
- package/dist/template/.pi/workflows/INDEX.md +2 -1
- package/dist/template/.pi/workflows/frontend-feature-workflow.md +343 -0
- package/dist/template/.pi/workflows/quality-loop.md +1 -1
- package/package.json +1 -1
- package/dist/template/.pi/prompts/loop-check.md +0 -87
- package/dist/template/.pi/prompts/loop-init.md +0 -157
- package/dist/template/.pi/prompts/loop-review.md +0 -90
- package/dist/template/.pi/skills/loop-audit/SKILL.md +0 -141
- package/dist/template/.pi/skills/loop-cost/SKILL.md +0 -130
- package/dist/template/.pi/skills/loop-engineering/SKILL.md +0 -175
- package/dist/template/.pi/templates/loop-github-action.yml +0 -162
- package/dist/template/.pi/templates/loop-orchestrator.sh +0 -514
- package/dist/template/.pi/templates/loop-orchestrator.test.ts +0 -332
- package/dist/template/.pi/templates/loop-orchestrator.ts +0 -936
- package/dist/template/.pi/templates/loop-state.json +0 -24
- package/dist/template/.pi/templates/loop-state.md +0 -98
- package/dist/template/.pi/templates/loop-vision.md +0 -110
- /package/dist/template/.pi/templates/{design.md → feature-design.md} +0 -0
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: production-hardening
|
|
3
|
+
description: Production hardening checklist for UI — i18n, error states, edge cases, loading states, empty states, validation, accessibility resilience
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Production Hardening
|
|
7
|
+
|
|
8
|
+
## When to Use
|
|
9
|
+
|
|
10
|
+
- Before deploying any user-facing UI to production
|
|
11
|
+
- When adding components to an existing production application
|
|
12
|
+
- During code review — check for edge cases and error states
|
|
13
|
+
- After initial implementation is working, before merging to main
|
|
14
|
+
- When building SaaS products, public websites, or any UI with real users
|
|
15
|
+
|
|
16
|
+
## When NOT to Use
|
|
17
|
+
|
|
18
|
+
- Early prototypes or throwaway demos
|
|
19
|
+
- Internal tools with no production exposure
|
|
20
|
+
- Non-UI code (backend services, CLIs, data pipelines)
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Text & Content
|
|
25
|
+
|
|
26
|
+
### Text Overflow & Truncation
|
|
27
|
+
|
|
28
|
+
Every text element must handle content that's longer than expected.
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
// BEFORE — long text breaks layout
|
|
32
|
+
<div className="grid grid-cols-3 gap-4">
|
|
33
|
+
{items.map(i => (
|
|
34
|
+
<div className="p-4 border rounded">
|
|
35
|
+
<h3>{i.title}</h3>
|
|
36
|
+
</div>
|
|
37
|
+
))}
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
// AFTER — content bounded
|
|
41
|
+
<div className="grid grid-cols-3 gap-4">
|
|
42
|
+
{items.map(i => (
|
|
43
|
+
<div className="p-4 border rounded min-w-0">
|
|
44
|
+
<h3 className="truncate">{i.title}</h3>
|
|
45
|
+
</div>
|
|
46
|
+
))}
|
|
47
|
+
</div>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```css
|
|
51
|
+
/* Common text overflow patterns */
|
|
52
|
+
.single-line-truncate {
|
|
53
|
+
overflow: hidden;
|
|
54
|
+
text-overflow: ellipsis;
|
|
55
|
+
white-space: nowrap;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.multi-line-truncate {
|
|
59
|
+
display: -webkit-box;
|
|
60
|
+
-webkit-line-clamp: 3;
|
|
61
|
+
-webkit-box-orient: vertical;
|
|
62
|
+
overflow: hidden;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* Long words/URLs — break them */
|
|
66
|
+
.long-word {
|
|
67
|
+
overflow-wrap: break-word;
|
|
68
|
+
word-break: break-word; /* legacy support */
|
|
69
|
+
hyphens: auto; /* adds hyphens at break points */
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Empty States
|
|
74
|
+
|
|
75
|
+
Never show a blank container. Every data-display component needs an empty state.
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
// BEFORE — blank container when no items
|
|
79
|
+
<div className="grid grid-cols-3 gap-4">
|
|
80
|
+
{items.map(i => <Card key={i.id} item={i} />)}
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
// AFTER — meaningful empty state
|
|
84
|
+
<div>
|
|
85
|
+
{items.length === 0 ? (
|
|
86
|
+
<div className="py-16 text-center">
|
|
87
|
+
<InboxIcon className="mx-auto h-12 w-12 text-muted-foreground/50" />
|
|
88
|
+
<h3 className="mt-4 text-lg font-semibold">No items yet</h3>
|
|
89
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
90
|
+
Get started by creating your first item.
|
|
91
|
+
</p>
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
onClick={onCreate}
|
|
95
|
+
className="mt-6 inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
|
|
96
|
+
>
|
|
97
|
+
<PlusIcon className="h-4 w-4" />
|
|
98
|
+
Create item
|
|
99
|
+
</button>
|
|
100
|
+
</div>
|
|
101
|
+
) : (
|
|
102
|
+
<div className="grid grid-cols-3 gap-4">
|
|
103
|
+
{items.map(i => <Card key={i.id} item={i} />)}
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Error States
|
|
110
|
+
|
|
111
|
+
Every data-fetching or stateful component needs an error state: what happened + why + how to fix.
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
// BEFORE — generic error, no recovery
|
|
115
|
+
{error && <p className="text-red-500">Something went wrong</p>}
|
|
116
|
+
|
|
117
|
+
// AFTER — actionable error with retry
|
|
118
|
+
{error && (
|
|
119
|
+
<div role="alert" className="rounded-lg border border-red-200 bg-red-50 p-4">
|
|
120
|
+
<div className="flex items-start gap-3">
|
|
121
|
+
<AlertCircleIcon className="h-5 w-5 text-red-500 shrink-0 mt-0.5" />
|
|
122
|
+
<div className="flex-1">
|
|
123
|
+
<h4 className="text-sm font-medium text-red-800">
|
|
124
|
+
Failed to load items
|
|
125
|
+
</h4>
|
|
126
|
+
<p className="mt-1 text-sm text-red-700">
|
|
127
|
+
{error.message || 'An unexpected error occurred. Please try again.'}
|
|
128
|
+
</p>
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
onClick={onRetry}
|
|
132
|
+
className="mt-3 text-sm font-medium text-red-800 underline hover:no-underline"
|
|
133
|
+
>
|
|
134
|
+
Try again
|
|
135
|
+
</button>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### i18n Readiness
|
|
143
|
+
|
|
144
|
+
Even if your app is English-only now, design for internationalization.
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
// BEFORE — English-specific patterns
|
|
148
|
+
<p>You have {count} items</p> // Pluralization hardcoded
|
|
149
|
+
<p>Posted on {date.toLocaleDateString()}</p> // Loses date format control
|
|
150
|
+
<input placeholder="Search..." /> // Placeholder as label
|
|
151
|
+
|
|
152
|
+
// AFTER — i18n-ready
|
|
153
|
+
<p>{t('items.count', { count })}</p> // Use i18n library with plural rules
|
|
154
|
+
<p>{new Intl.DateTimeFormat(locale).format(date)}</p> // Respects locale
|
|
155
|
+
<label htmlFor="search">{t('search.label')}</label>
|
|
156
|
+
<input id="search" placeholder={t('search.placeholder')} />
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
```css
|
|
160
|
+
/* Allow 30% text expansion for translations */
|
|
161
|
+
.fixed-width-button {
|
|
162
|
+
min-width: 120px; /* English: "Save" */
|
|
163
|
+
padding: 0.5rem 1rem; /* German: "Speichern" — needs ~2x width */
|
|
164
|
+
}
|
|
165
|
+
.flexible-layout {
|
|
166
|
+
/* Use min-width/max-width, not fixed width, so text can expand */
|
|
167
|
+
min-width: 80px;
|
|
168
|
+
padding-inline: 1rem;
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**i18n checklist:**
|
|
173
|
+
|
|
174
|
+
| Concern | Check |
|
|
175
|
+
|---------|-------|
|
|
176
|
+
| Text expansion | UI handles 30%+ longer text in other languages |
|
|
177
|
+
| RTL support | Layout uses logical properties (`margin-inline-start` not `margin-left`) |
|
|
178
|
+
| Date/number formatting | Uses `Intl.DateTimeFormat`, `Intl.NumberFormat` |
|
|
179
|
+
| String concatenation | No `"Hello " + name` — use template strings or i18n library |
|
|
180
|
+
| Pluralization | Uses CLDR plural rules (one, few, many, other) |
|
|
181
|
+
| Sorting | Uses `localeCompare` for language-aware sorting |
|
|
182
|
+
| Currency | Uses `Intl.NumberFormat` with `style: 'currency'` |
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Interaction
|
|
187
|
+
|
|
188
|
+
### Loading States
|
|
189
|
+
|
|
190
|
+
Every async operation needs a loading state. Use skeleton loaders for content, spinners only for actions.
|
|
191
|
+
|
|
192
|
+
```tsx
|
|
193
|
+
// BEFORE — content flashes on load
|
|
194
|
+
{data && <DataTable data={data} />}
|
|
195
|
+
|
|
196
|
+
// AFTER — skeleton while loading, content when ready
|
|
197
|
+
{loading ? (
|
|
198
|
+
<TableSkeleton rows={5} columns={4} />
|
|
199
|
+
) : error ? (
|
|
200
|
+
<ErrorState error={error} onRetry={refetch} />
|
|
201
|
+
) : (
|
|
202
|
+
<DataTable data={data} />
|
|
203
|
+
)}
|
|
204
|
+
|
|
205
|
+
// For action buttons:
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
disabled={isSubmitting}
|
|
209
|
+
className="..."
|
|
210
|
+
>
|
|
211
|
+
{isSubmitting ? (
|
|
212
|
+
<>
|
|
213
|
+
<SpinnerIcon className="h-4 w-4 animate-spin" aria-hidden="true" />
|
|
214
|
+
<span className="sr-only">Saving...</span>
|
|
215
|
+
Saving
|
|
216
|
+
</>
|
|
217
|
+
) : (
|
|
218
|
+
'Save'
|
|
219
|
+
)}
|
|
220
|
+
</button>
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Disabled States
|
|
224
|
+
|
|
225
|
+
Disabled states must communicate *why* something is disabled, not just gray it out.
|
|
226
|
+
|
|
227
|
+
```tsx
|
|
228
|
+
// BEFORE — just grayed out, no explanation
|
|
229
|
+
<button type="button" disabled={!canSubmit} className="opacity-50 cursor-not-allowed">
|
|
230
|
+
Submit
|
|
231
|
+
</button>
|
|
232
|
+
|
|
233
|
+
// AFTER — with tooltip explaining why
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
disabled={!canSubmit}
|
|
237
|
+
className="opacity-50 cursor-not-allowed"
|
|
238
|
+
title={!canSubmit ? 'Complete all required fields first' : undefined}
|
|
239
|
+
aria-disabled={!canSubmit}
|
|
240
|
+
>
|
|
241
|
+
Submit
|
|
242
|
+
</button>
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Focus Visible
|
|
246
|
+
|
|
247
|
+
Never remove focus outlines entirely. Use `:focus-visible` for mouse/keyboard differentiation.
|
|
248
|
+
|
|
249
|
+
```css
|
|
250
|
+
/* Correct pattern */
|
|
251
|
+
*:focus-visible {
|
|
252
|
+
outline: 2px solid var(--color-ring);
|
|
253
|
+
outline-offset: 2px;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/* Remove only for mouse focus (never for keyboard) */
|
|
257
|
+
*:focus:not(:focus-visible) {
|
|
258
|
+
outline: none;
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Keyboard Trap Prevention
|
|
263
|
+
|
|
264
|
+
Never trap keyboard focus without a documented escape mechanism.
|
|
265
|
+
|
|
266
|
+
```tsx
|
|
267
|
+
// In modals — always close on Escape
|
|
268
|
+
useEffect(() => {
|
|
269
|
+
if (!open) return;
|
|
270
|
+
const handler = (e: KeyboardEvent) => {
|
|
271
|
+
if (e.key === 'Escape') onClose();
|
|
272
|
+
};
|
|
273
|
+
document.addEventListener('keydown', handler);
|
|
274
|
+
return () => document.removeEventListener('keydown', handler);
|
|
275
|
+
}, [open, onClose]);
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
## Data & Validation
|
|
281
|
+
|
|
282
|
+
### Input Validation
|
|
283
|
+
|
|
284
|
+
Every input needs validation at both the HTML5 level and JavaScript level.
|
|
285
|
+
|
|
286
|
+
```tsx
|
|
287
|
+
// BEFORE — no validation
|
|
288
|
+
<input
|
|
289
|
+
type="text"
|
|
290
|
+
value={value}
|
|
291
|
+
onChange={(e) => setValue(e.target.value)}
|
|
292
|
+
/>
|
|
293
|
+
|
|
294
|
+
// AFTER — HTML5 + JS validation
|
|
295
|
+
<input
|
|
296
|
+
type="email"
|
|
297
|
+
value={value}
|
|
298
|
+
onChange={handleChange}
|
|
299
|
+
required
|
|
300
|
+
maxLength={254}
|
|
301
|
+
pattern="[^@\s]+@[^@\s]+\.[^@\s]+"
|
|
302
|
+
aria-invalid={!!error}
|
|
303
|
+
aria-describedby={error ? 'email-error' : undefined}
|
|
304
|
+
className={cn(
|
|
305
|
+
'rounded-md border px-3 py-2',
|
|
306
|
+
error ? 'border-red-500' : 'border-input'
|
|
307
|
+
)}
|
|
308
|
+
/>
|
|
309
|
+
{error && (
|
|
310
|
+
<p id="email-error" className="mt-1 text-sm text-red-500" role="alert">
|
|
311
|
+
{error}
|
|
312
|
+
</p>
|
|
313
|
+
)}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**Validation rules by type:**
|
|
317
|
+
|
|
318
|
+
| Input Type | Validation |
|
|
319
|
+
|-----------|------------|
|
|
320
|
+
| Email | RegExp pattern + maxLength 254 |
|
|
321
|
+
| URL | URL parser + protocol check (http/https) |
|
|
322
|
+
| Phone | Strip formatting, validate digits only |
|
|
323
|
+
| Number | min/max + step if decimal |
|
|
324
|
+
| Text | maxLength + sanitize HTML |
|
|
325
|
+
| Password | minLength 8 + complexity rules |
|
|
326
|
+
|
|
327
|
+
### Input Sanitization
|
|
328
|
+
|
|
329
|
+
Never trust user input — sanitize before rendering.
|
|
330
|
+
|
|
331
|
+
```tsx
|
|
332
|
+
// BEFORE — XSS risk
|
|
333
|
+
<div>{userProvidedContent}</div>
|
|
334
|
+
|
|
335
|
+
// AFTER — sanitize HTML content
|
|
336
|
+
import DOMPurify from 'dompurify';
|
|
337
|
+
|
|
338
|
+
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userProvidedContent) }} />
|
|
339
|
+
|
|
340
|
+
// Better — avoid dangerouslySetInnerHTML entirely if possible
|
|
341
|
+
// Most content can be rendered as text:
|
|
342
|
+
<div>{escapeHtml(userProvidedContent)}</div>
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### Max Lengths
|
|
346
|
+
|
|
347
|
+
Every text input needs a maximum length — database columns aren't infinite.
|
|
348
|
+
|
|
349
|
+
```tsx
|
|
350
|
+
// Show character count for textual inputs
|
|
351
|
+
<div className="relative">
|
|
352
|
+
<textarea
|
|
353
|
+
maxLength={500}
|
|
354
|
+
value={bio}
|
|
355
|
+
onChange={handleBioChange}
|
|
356
|
+
className="..."
|
|
357
|
+
/>
|
|
358
|
+
<span className="absolute bottom-2 right-2 text-xs text-muted-foreground">
|
|
359
|
+
{bio.length}/500
|
|
360
|
+
</span>
|
|
361
|
+
</div>
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### Offline States
|
|
365
|
+
|
|
366
|
+
Detect and handle offline/network issues gracefully.
|
|
367
|
+
|
|
368
|
+
```tsx
|
|
369
|
+
function OfflineBanner() {
|
|
370
|
+
const [online, setOnline] = useState(navigator.onLine);
|
|
371
|
+
|
|
372
|
+
useEffect(() => {
|
|
373
|
+
const goOnline = () => setOnline(true);
|
|
374
|
+
const goOffline = () => setOnline(false);
|
|
375
|
+
window.addEventListener('online', goOnline);
|
|
376
|
+
window.addEventListener('offline', goOffline);
|
|
377
|
+
return () => {
|
|
378
|
+
window.removeEventListener('online', goOnline);
|
|
379
|
+
window.removeEventListener('offline', goOffline);
|
|
380
|
+
};
|
|
381
|
+
}, []);
|
|
382
|
+
|
|
383
|
+
if (online) return null;
|
|
384
|
+
|
|
385
|
+
return (
|
|
386
|
+
<div role="alert" className="bg-amber-50 border-b border-amber-200 px-4 py-2 text-center text-sm text-amber-800">
|
|
387
|
+
You're offline. Some features may be unavailable.
|
|
388
|
+
</div>
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
### Must-Use-Real-Data
|
|
394
|
+
|
|
395
|
+
Every data-display component must receive realistic, domain-appropriate data. This is non-negotiable for production UI.
|
|
396
|
+
|
|
397
|
+
```tsx
|
|
398
|
+
// BEFORE — generic slop (NEVER do this)
|
|
399
|
+
<TestimonialCard name="Jane Doe" quote="Amazing product! Highly recommended." />
|
|
400
|
+
<StatCard label="Revenue" value="$99/mo" />
|
|
401
|
+
<HeroSection title="Unleash Your Potential with Our Powerful Platform" />
|
|
402
|
+
|
|
403
|
+
// AFTER — realistic data with domain specificity
|
|
404
|
+
<TestimonialCard name="Dr. Sarah Chen" quote="Reduced our deployment time from 3 days to 45 minutes." />
|
|
405
|
+
<StatCard label="Revenue" value="$12,450" trend="+18.3%" />
|
|
406
|
+
<HeroSection title="Deploy infrastructure changes in under 10 minutes" />
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
**Rule:** Every text string must be real or realistically plausible for the domain.
|
|
410
|
+
|
|
411
|
+
| Pattern | Replacement | Because |
|
|
412
|
+
|---------|-------------|---------|
|
|
413
|
+
| `Lorem ipsum dolor sit amet...` | Context-aware placeholder text | Lorem ipsum signals demo/throwaway quality |
|
|
414
|
+
| "Jane Doe", "John Smith" | Realistic names with context | Generic names make UI feel fake |
|
|
415
|
+
| "$99/mo", "$49" | Domain-realistic numbers | Fake stock pricing looks like a template |
|
|
416
|
+
| "amazing", "powerful", "unleash", "revolutionary" | Specific, verifiable claims | Filler adjectives are startup clichés |
|
|
417
|
+
| "Highly recommended!" | Specific outcome statement | Generic testimonials destroy credibility |
|
|
418
|
+
|
|
419
|
+
---
|
|
420
|
+
|
|
421
|
+
## Edge Cases
|
|
422
|
+
|
|
423
|
+
### Zero Items
|
|
424
|
+
|
|
425
|
+
```tsx
|
|
426
|
+
// Empty state (described above)
|
|
427
|
+
{items.length === 0 && <EmptyState />}
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
### Single Item
|
|
431
|
+
|
|
432
|
+
```tsx
|
|
433
|
+
// BEFORE — single-item layout breaks grid
|
|
434
|
+
<div className="grid grid-cols-3 gap-4">
|
|
435
|
+
{items.map(i => <Card key={i.id} item={i} />)}
|
|
436
|
+
</div>
|
|
437
|
+
|
|
438
|
+
// AFTER — auto-fill handles any count
|
|
439
|
+
<div className="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-4">
|
|
440
|
+
{items.map(i => <Card key={i.id} item={i} />)}
|
|
441
|
+
</div>
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
### Many Items (Performance)
|
|
445
|
+
|
|
446
|
+
```tsx
|
|
447
|
+
// Virtualize long lists — use react-virtuoso or tanstack-virtual
|
|
448
|
+
import { Virtuoso } from 'react-virtuoso';
|
|
449
|
+
|
|
450
|
+
// BEFORE — renders all 10,000 items
|
|
451
|
+
<div>
|
|
452
|
+
{items.map(i => <Row key={i.id} item={i} />)}
|
|
453
|
+
</div>
|
|
454
|
+
|
|
455
|
+
// AFTER — only renders visible items
|
|
456
|
+
<Virtuoso
|
|
457
|
+
totalCount={items.length}
|
|
458
|
+
itemContent={(index) => <Row item={items[index]} />}
|
|
459
|
+
style={{ height: '600px' }}
|
|
460
|
+
/>
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
### Very Long Names
|
|
464
|
+
|
|
465
|
+
```tsx
|
|
466
|
+
// BEFORE — long name breaks layout
|
|
467
|
+
<UserCard>
|
|
468
|
+
<Avatar />
|
|
469
|
+
<span>{user.name}</span> {/* "Dr. Maximilian von Schtuffenheimer III" */}
|
|
470
|
+
</UserCard>
|
|
471
|
+
|
|
472
|
+
// AFTER — constrained
|
|
473
|
+
<UserCard className="min-w-0">
|
|
474
|
+
<Avatar />
|
|
475
|
+
<span className="truncate" title={user.name}>{user.name}</span>
|
|
476
|
+
</UserCard>
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
### Missing Images
|
|
480
|
+
|
|
481
|
+
```tsx
|
|
482
|
+
// BEFORE — broken image icon when src fails
|
|
483
|
+
<img src={user.avatar} alt={user.name} />
|
|
484
|
+
|
|
485
|
+
// AFTER — fallback for broken image
|
|
486
|
+
<img
|
|
487
|
+
src={user.avatar}
|
|
488
|
+
alt={user.name}
|
|
489
|
+
onError={(e) => {
|
|
490
|
+
e.currentTarget.src = '/avatars/default.svg';
|
|
491
|
+
e.currentTarget.onerror = null; // prevent infinite loop
|
|
492
|
+
}}
|
|
493
|
+
/>
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
### Slow Network
|
|
497
|
+
|
|
498
|
+
```tsx
|
|
499
|
+
// Show loading state immediately, even on fast connections
|
|
500
|
+
// Use React.Suspense + streaming where possible
|
|
501
|
+
// Never show blank page while data loads
|
|
502
|
+
|
|
503
|
+
// Loading skeleton that appears instantly:
|
|
504
|
+
const { data, isLoading } = useQuery({
|
|
505
|
+
queryKey: ['items'],
|
|
506
|
+
queryFn: fetchItems,
|
|
507
|
+
staleTime: 30_000,
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
if (isLoading) return <ItemsSkeleton />;
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
### JS Disabled
|
|
514
|
+
|
|
515
|
+
```html
|
|
516
|
+
<!-- In the <head> of your HTML -->
|
|
517
|
+
<noscript>
|
|
518
|
+
<div style="padding: 2rem; text-align: center;">
|
|
519
|
+
<p>This application requires JavaScript to function.</p>
|
|
520
|
+
<p>Please enable JavaScript in your browser settings.</p>
|
|
521
|
+
</div>
|
|
522
|
+
</noscript>
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
---
|
|
526
|
+
|
|
527
|
+
## Cross-Browser
|
|
528
|
+
|
|
529
|
+
### `-webkit-appearance`
|
|
530
|
+
|
|
531
|
+
```css
|
|
532
|
+
/* BEFORE — native styling differs across browsers */
|
|
533
|
+
select, input[type="search"] {
|
|
534
|
+
/* no reset */
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/* AFTER — consistent cross-browser base */
|
|
538
|
+
select, input[type="search"], input[type="number"] {
|
|
539
|
+
-webkit-appearance: none;
|
|
540
|
+
-moz-appearance: none;
|
|
541
|
+
appearance: none;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/* Restore arrow for select if desired */
|
|
545
|
+
select {
|
|
546
|
+
background-image: url("data:image/svg+xml,...");
|
|
547
|
+
background-repeat: no-repeat;
|
|
548
|
+
background-position: right 0.5rem center;
|
|
549
|
+
background-size: 1.5em;
|
|
550
|
+
padding-right: 2.5rem;
|
|
551
|
+
}
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
### Scrollbar Styling
|
|
555
|
+
|
|
556
|
+
```css
|
|
557
|
+
/* Consistent scrollbar across browsers */
|
|
558
|
+
.custom-scrollbar {
|
|
559
|
+
scrollbar-width: thin; /* Firefox */
|
|
560
|
+
scrollbar-color: hsl(0 0% 60%) transparent; /* Firefox */
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
.custom-scrollbar::-webkit-scrollbar {
|
|
564
|
+
width: 6px;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
.custom-scrollbar::-webkit-scrollbar-track {
|
|
568
|
+
background: transparent;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
.custom-scrollbar::-webkit-scrollbar-thumb {
|
|
572
|
+
background-color: hsl(0 0% 60%);
|
|
573
|
+
border-radius: 3px;
|
|
574
|
+
}
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
### Font Smoothing
|
|
578
|
+
|
|
579
|
+
```css
|
|
580
|
+
body {
|
|
581
|
+
-webkit-font-smoothing: antialiased;
|
|
582
|
+
-moz-osx-font-smoothing: grayscale;
|
|
583
|
+
text-rendering: optimizeLegibility;
|
|
584
|
+
}
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
### Safe Area Insets
|
|
588
|
+
|
|
589
|
+
```css
|
|
590
|
+
/* For notched devices (iOS) */
|
|
591
|
+
.safe-area {
|
|
592
|
+
padding-top: env(safe-area-inset-top);
|
|
593
|
+
padding-bottom: env(safe-area-inset-bottom);
|
|
594
|
+
padding-left: env(safe-area-inset-left);
|
|
595
|
+
padding-right: env(safe-area-inset-right);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/* For fixed bottom bars */
|
|
599
|
+
.bottom-bar {
|
|
600
|
+
position: fixed;
|
|
601
|
+
bottom: 0;
|
|
602
|
+
left: 0;
|
|
603
|
+
right: 0;
|
|
604
|
+
padding-bottom: calc(1rem + env(safe-area-inset-bottom));
|
|
605
|
+
}
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
---
|
|
609
|
+
|
|
610
|
+
## Don't
|
|
611
|
+
|
|
612
|
+
| Pattern | Replacement | Because |
|
|
613
|
+
|---------|-------------|---------|
|
|
614
|
+
| Long text breaking layout | Apply `truncate`, `overflow-wrap: break-word`, or `-webkit-line-clamp` | Unbounded text breaks grid layouts |
|
|
615
|
+
| Blank container when no items | Meaningful empty state with icon, message, and CTA | Blank screens confuse users |
|
|
616
|
+
| Generic error ("Something went wrong") | Actionable error: what happened + why + how to fix | Users need to know what to do next |
|
|
617
|
+
| Content flash on load (no loading state) | Skeleton loaders matching final layout | Instant skeleton prevents layout shift |
|
|
618
|
+
| Just grayed-out disabled button | Tooltip explaining why it's disabled | Users need to know why they can't proceed |
|
|
619
|
+
| No input validation | HTML5 + JS validation with `aria-describedby` for errors | Unvalidated input causes data integrity issues |
|
|
620
|
+
| No fallback for broken image src | `onError` handler to swap to default image | Broken image icons look unprofessional |
|
|
621
|
+
| Unsanitized user content rendering | `DOMPurify.sanitize()` or text-only rendering | Raw user content is an XSS vulnerability |
|
|
622
|
+
|
|
623
|
+
## Verification
|
|
624
|
+
|
|
625
|
+
- [ ] All text content handles overflow — truncation or word-break applied
|
|
626
|
+
- [ ] Every data-display component has an empty state (not blank container)
|
|
627
|
+
- [ ] Every async operation has loading state (skeleton preferred)
|
|
628
|
+
- [ ] Every async operation has error state (what + why + fix/retry)
|
|
629
|
+
- [ ] All form inputs have maxLength bounds
|
|
630
|
+
- [ ] All user-provided content is sanitized before rendering
|
|
631
|
+
- [ ] Disabled states explain *why* the element is disabled
|
|
632
|
+
- [ ] `:focus-visible` is implemented on all interactive elements
|
|
633
|
+
- [ ] No keyboard traps — all modals close on Escape
|
|
634
|
+
- [ ] Input validation at HTML5 level + JavaScript level
|
|
635
|
+
- [ ] Offline state is detected and communicated to user
|
|
636
|
+
- [ ] Single-item layouts don't break (use auto-fill grid)
|
|
637
|
+
- [ ] Long lists (100+ items) use virtualization or pagination
|
|
638
|
+
- [ ] Very long names/words are truncated or broken
|
|
639
|
+
- [ ] Images have `onError` fallback for broken src
|
|
640
|
+
- [ ] Safe area insets applied to fixed-position elements
|
|
641
|
+
- [ ] `-webkit-appearance` reset on form elements for cross-browser consistency
|
|
642
|
+
- [ ] Font smoothing applied to body
|
|
643
|
+
- [ ] i18n-ready: text allows 30% expansion, uses logical properties, uses `Intl.*` for dates/numbers
|
|
644
|
+
- [ ] `noscript` fallback present in HTML
|
|
645
|
+
|
|
646
|
+
### Self-Critique (Run Before Output)
|
|
647
|
+
|
|
648
|
+
1. **State Coverage:** Does every async component handle loading, empty, error, AND success states? No "it just works" assumptions.
|
|
649
|
+
2. **Data Realism:** Are all names, numbers, dates, and text strings realistic? No lorem ipsum, no Jane Doe, no filler content.
|
|
650
|
+
3. **Boundary Check:** Do inputs have maxLength? Do lists handle 0, 1, 100+ items? Do long names/words get truncated?
|
|
651
|
+
4. **Accessibility Resilience:** Is every interactive element keyboard-accessible? No keyboard traps? Focus visible on all elements?
|
|
652
|
+
5. **Error Recovery:** Does every error state explain what happened, why, and how to fix it? No "Something went wrong" alone.
|