Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/DISCUSSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ Sorted by descending date (most recent first).

---

# Session: PERF — Lighthouse Score Improvements (CLS, Accessibility, SEO)
**Date:** 2026-05-07
**Context and Problem to Solve**

A production Lighthouse audit against `https://ecommerce.devoyager.cloud/` returned Performance 78/100, Accessibility 79/100, SEO 83/100, Best Practices 100/100. The Performance failure was driven entirely by CLS 0.602 — every other metric (FCP 0.4 s, LCP 0.5 s, TBT 0 ms) was already excellent. The CLS originated from the product list skeleton being structurally different from the loaded product grid (different column count, `h-80` fixed heights vs. `aspect-square` cards), causing a large height change when the async data resolved. Accessibility failures were icon-button labelling and insufficient contrast ratios. SEO failures were a missing meta description and `robots.txt` returning the SPA's `index.html` fallback.

**Summary of Decisions**

| Decision | Rationale |
|---|---|
| **Replace flat skeleton with card-shaped skeleton** | The original skeleton used `h-80` divs in a mismatched 4-column grid. When 8+ products loaded into a 4-col/3-col/2-col responsive grid with `aspect-square` image containers, the height jump caused CLS 0.602. The new skeleton uses the identical grid classes and card structure (aspect-square image placeholder, text stubs, button stub) so the height change on load is negligible. |
| **`<picture>` + WebP with PNG fallback** | Product images were served as PNG (up to 433 KB). Converting to WebP and wrapping in `<picture>` reduced transfer sizes by 92–95% (433 KB → 31 KB for the largest) without changing the `imageUrl` field in MongoDB. The `webpUrl()` helper in each component derives the `.webp` path from the `.png` URL; browsers without WebP support fall back to the PNG `<img src>`. |
| **`width`/`height` on `<img>` tags** | Explicit intrinsic dimensions allow the browser to reserve space before the image response arrives, preventing any residual CLS from image-specific load timing. |
| **`h1` → `h2` in product-list** | The product-list component is embedded inside `home.html` which already has an `h1`. A second `h1` broke heading hierarchy on the home page. Changing to `h2` fixes the hierarchy on both the home page and the standalone `/products` route. |
| **`h3` → `h2` for "Description" in product-details** | The product name was `h1`; "Description" was `h3` with no intervening `h2`, triggering Lighthouse's heading-skip audit. |
| **`text-slate-400` → `text-slate-600` for footer and description label** | `text-slate-400` on white background has contrast ratio 2.63, below the WCAG AA minimum of 4.5:1. `text-slate-600` achieves ~5.9:1. |
| **`robots.txt` in `public/`** | Nginx was serving `index.html` for `/robots.txt` requests because no such file existed and the SPA fallback catches all unknown paths. A real `robots.txt` in `public/` is copied to the build output root and served directly. |

---

# Session: PERF — Cold Start Fix and Lighthouse Protocol
**Date:** 2026-05-07
**Context and Problem to Solve**
Expand Down
Binary file added frontend/public/product-images/iphone-15.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions frontend/public/robots.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
User-agent: *
Allow: /
2 changes: 1 addition & 1 deletion frontend/src/app/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@

<footer class="w-full border-t border-slate-200 bg-white py-6">
<div class="container mx-auto px-4 sm:px-6 flex justify-center items-center">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] select-none">
<p class="text-[10px] font-black text-slate-600 uppercase tracking-[0.2em] select-none">
&copy; {{ currentYear }} E-Shop Inc.
</p>
</div>
Expand Down
16 changes: 10 additions & 6 deletions frontend/src/app/components/product-details/product-details.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-12 bg-white p-8 rounded-3xl border border-slate-100 shadow-xl shadow-slate-200/50">
<div class="aspect-square rounded-2xl bg-slate-50 flex items-center justify-center border border-slate-100">
@if (product.imageUrl) {
<img [src]="product.imageUrl" [alt]="product.name" [title]="product.name"
class="w-full h-full object-contain p-4" />
<picture>
<source [srcset]="webpUrl(product.imageUrl)" type="image/webp">
<img [src]="product.imageUrl" [alt]="product.name" title="{{ product.name }}"
width="600" height="600"
class="w-full h-full object-contain p-4" />
</picture>
} @else {
<span class="text-[120px] font-black text-slate-200 select-none">{{ product.name.charAt(0) }}</span>
}
Expand All @@ -25,20 +29,20 @@ <h1 class="mt-4 text-4xl font-black tracking-tighter text-slate-900">{{ product.
</div>

<div class="space-y-2">
<h3 class="text-xs font-bold uppercase tracking-widest text-slate-400">Description</h3>
<h2 class="text-xs font-bold uppercase tracking-widest text-slate-600">Description</h2>
<p class="text-slate-600 leading-relaxed font-medium">{{ product.description }}</p>
</div>

<div class="pt-6 border-t border-slate-100 space-y-6">
<div class="flex items-center gap-4">
<div class="flex items-center rounded-xl bg-slate-100 p-1">
<button (click)="quantity.set(quantity() > 1 ? quantity() - 1 : 1)" class="px-4 py-2 font-bold text-slate-500">-</button>
<button type="button" aria-label="Decrease quantity" (click)="quantity.set(quantity() > 1 ? quantity() - 1 : 1)" class="px-4 py-2 font-bold text-slate-500">-</button>
<span class="w-12 text-center font-black text-slate-900">{{ quantity() }}</span>
<button (click)="quantity.set(quantity() + 1)" class="px-4 py-2 font-bold text-slate-500">+</button>
<button type="button" aria-label="Increase quantity" (click)="quantity.set(quantity() + 1)" class="px-4 py-2 font-bold text-slate-500">+</button>
</div>
</div>

<button (click)="handleAddToCart()"
<button type="button" (click)="handleAddToCart()"
[disabled]="product.stockQuantity === 0 || isLoading()"
class="w-full py-5 bg-slate-900 text-white font-black uppercase tracking-widest rounded-2xl hover:bg-indigo-600 shadow-xl shadow-indigo-100 transition-all active:scale-95 disabled:bg-slate-300">
@if (isLoading()) { Adding to Cart... } @else { Add to Cart }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export class ProductDetails implements OnInit {
}
}

webpUrl(imageUrl: string): string {
return imageUrl.replace(/\.(png|jpe?g)$/i, '.webp');
}

async handleAddToCart() {
if (!this.authService.isLoggedIn()) {
this.router.navigate(['/login']);
Expand Down
31 changes: 24 additions & 7 deletions frontend/src/app/components/product-list/product-list.html
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<section class="space-y-8">
<header class="flex flex-col gap-2 border-l-4 border-indigo-600 pl-4">
<h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl text-slate-900 uppercase">
<h2 class="text-3xl font-extrabold tracking-tight sm:text-4xl text-slate-900 uppercase">
Premium Collection
</h1>
</h2>
<p class="text-slate-500 font-medium">Curated high-quality items just for you.</p>
</header>

Expand All @@ -18,8 +18,12 @@ <h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl text-slate-900 upp
[routerLink]="['/product', product.id]"
>
@if (product.imageUrl) {
<img [src]="product.imageUrl" [alt]="product.name" [title]="product.name"
class="w-full h-full object-contain transition-transform duration-500 group-hover:scale-110" />
<picture>
<source [srcset]="webpUrl(product.imageUrl)" type="image/webp">
<img [src]="product.imageUrl" [alt]="product.name" title="{{ product.name }}"
width="400" height="400"
class="w-full h-full object-contain transition-transform duration-500 group-hover:scale-110" />
</picture>
} @else {
<span
class="text-6xl font-black text-slate-200 transition-transform duration-500 group-hover:scale-110 select-none"
Expand Down Expand Up @@ -51,8 +55,11 @@ <h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl text-slate-900 upp
<span class="text-xl font-black text-slate-900">{{ product.price | currency }}</span>

<button
type="button"
(click)="handleAddToCart(product)"
[disabled]="product.stockQuantity === 0"
[attr.aria-label]="'Add ' + product.name + ' to cart'"
[attr.title]="'Add ' + product.name + ' to cart'"
class="rounded-full bg-slate-900 p-3 text-white transition-all hover:bg-indigo-600 disabled:bg-slate-300 disabled:cursor-not-allowed shadow-md active:scale-90"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
Expand All @@ -71,9 +78,19 @@ <h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl text-slate-900 upp
</div>

} @else {
<div class="animate-pulse grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4">
@for (i of [1,2,3,4]; track i) {
<div class="h-80 bg-slate-200 rounded-2xl"></div>
<div class="grid grid-cols-1 gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
@for (i of [1,2,3,4,5,6,7,8]; track i) {
<div class="animate-pulse rounded-3xl border border-slate-200 bg-white p-4">
<div class="aspect-square rounded-2xl bg-slate-200"></div>
<div class="mt-4 space-y-2">
<div class="h-2 w-1/4 rounded bg-slate-200"></div>
<div class="h-4 w-3/4 rounded bg-slate-200"></div>
<div class="mt-4 flex items-center justify-between">
<div class="h-6 w-1/3 rounded bg-slate-200"></div>
<div class="h-11 w-11 rounded-full bg-slate-200"></div>
</div>
</div>
</div>
}
</div>
}
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/app/components/product-list/product-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export class ProductList implements OnInit {
this.products$ = this.productService.getAvailableProducts();
}

webpUrl(imageUrl: string): string {
return imageUrl.replace(/\.(png|jpe?g)$/i, '.webp');
}

async handleAddToCart(product: ProductResponse) {
if (!this.authService.isLoggedIn()) {
this.router.navigate(['/login']);
Expand Down
1 change: 1 addition & 0 deletions frontend/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<title>E-Shop</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="E-Shop — curated premium products for the modern individual. Browse our collection of quality lifestyle essentials.">
<link rel="icon" type="image/x-icon" href="favicon.svg">
</head>
<body>
Expand Down
Loading