@minhduydev/mdpi 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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.