perf(homepage): serve product screenshots as resized WebP to lift Lighthouse perf above 0.95
All checks were successful
ci / build-test (push) Successful in 4m56s
All checks were successful
ci / build-test (push) Successful in 4m56s
The homepage Lighthouse perf score dropped to 0.94 in CI (threshold 0.95) because Hero and ThreeAmWalkthrough each load a 1920×945 PNG screenshot straight out of public/product/ — together ~1.2 MiB and the LCP element. Other pages have no product imagery and pass with 1.0. Adds a sharp-based generator (scripts/optimize-product-images.mjs, run via `npm run optimize:images`) that emits 1280w and 1920w WebP variants beside each source PNG. Lightbox.astro now wraps the trigger in a <picture> with a WebP <source srcset> auto-derived from the PNG path (PNG kept as fallback for the ~2% of clients without WebP), exposes a fetchpriority prop, and points the dialog modal at the 1920w WebP. The Hero passes fetchpriority="high" on the LCP image; index.astro injects a matching <link rel="preload" as="image" imagesrcset> via a new BaseLayout head slot so the WebP is discovered before the body parses. Effect on the LCP image: 551 KiB PNG → 46 KiB WebP at 1280w (-92%). Local Lighthouse perf: 0.95 → 1.00 across 3 runs on /index.html; pricing/imprint stay 1.00; privacy unchanged at 0.98 (pre-existing CLS). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,9 +5,14 @@ interface Props {
|
||||
width: number;
|
||||
height: number;
|
||||
loading?: 'eager' | 'lazy';
|
||||
fetchpriority?: 'high' | 'low' | 'auto';
|
||||
caption?: string;
|
||||
triggerClass?: string;
|
||||
imgClass?: string;
|
||||
/** Set to false for sources without sibling .webp variants. */
|
||||
optimized?: boolean;
|
||||
/** CSS sizes attribute for the WebP srcset. Defaults to a mobile-first guess. */
|
||||
sizes?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -16,11 +21,20 @@ const {
|
||||
width,
|
||||
height,
|
||||
loading = 'lazy',
|
||||
fetchpriority,
|
||||
caption,
|
||||
triggerClass = '',
|
||||
imgClass = 'block w-full h-auto',
|
||||
optimized = true,
|
||||
sizes = '(min-width: 1024px) 56vw, 100vw',
|
||||
} = Astro.props;
|
||||
|
||||
// Auto-derive WebP variant paths from the PNG src. The optimize-product-images
|
||||
// script emits <name>-1280.webp and <name>-1920.webp next to each <name>.png.
|
||||
const webpBase = optimized && /\.png$/i.test(src) ? src.replace(/\.png$/i, '') : null;
|
||||
const webpSrcset = webpBase ? `${webpBase}-1280.webp 1280w, ${webpBase}-1920.webp 1920w` : null;
|
||||
const webpDialogSrc = webpBase ? `${webpBase}-1920.webp` : src;
|
||||
|
||||
// Unique per-instance id so multiple lightboxes on a page do not collide.
|
||||
const dialogId = `lb-${Math.random().toString(36).slice(2, 10)}`;
|
||||
---
|
||||
@@ -30,15 +44,32 @@ const dialogId = `lb-${Math.random().toString(36).slice(2, 10)}`;
|
||||
data-lightbox-open={dialogId}
|
||||
aria-label={`Enlarge: ${alt}`}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
loading={loading}
|
||||
decoding="async"
|
||||
class={imgClass}
|
||||
/>
|
||||
{webpSrcset ? (
|
||||
<picture>
|
||||
<source type="image/webp" srcset={webpSrcset} sizes={sizes} />
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
loading={loading}
|
||||
decoding="async"
|
||||
fetchpriority={fetchpriority}
|
||||
class={imgClass}
|
||||
/>
|
||||
</picture>
|
||||
) : (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
loading={loading}
|
||||
decoding="async"
|
||||
fetchpriority={fetchpriority}
|
||||
class={imgClass}
|
||||
/>
|
||||
)}
|
||||
<span aria-hidden="true" class="lightbox-zoom-badge">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="7.5"/>
|
||||
@@ -59,7 +90,7 @@ const dialogId = `lb-${Math.random().toString(36).slice(2, 10)}`;
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
<img src={src} alt={alt} class="lightbox-image" />
|
||||
<img src={webpDialogSrc} alt={alt} class="lightbox-image" loading="lazy" decoding="async" />
|
||||
{caption && <p class="lightbox-caption">{caption}</p>}
|
||||
</dialog>
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ const pins: Pin[] = [
|
||||
width={1920}
|
||||
height={945}
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
<div class="absolute inset-0 ring-1 ring-inset ring-accent/10 pointer-events-none rounded-lg"></div>
|
||||
{pins.map((pin) => (
|
||||
|
||||
Reference in New Issue
Block a user