feat: serve the marketing site photos through astro:assets as AVIF with WebP fallback, responsive widths and an inline blurred placeholder via a new Photo component, move the textures into src/assets, cache built assets immutably, and let ScaledStage render a plain frame when no photo is given

This commit is contained in:
Matthew Meszaros
2026-10-09 18:33:49 +02:00
parent e41587d404
commit a4dd7e70b9
26 changed files with 67 additions and 7 deletions
+1
View File
@@ -19,6 +19,7 @@
"astro": "^7.2.8",
"motion": "^12.34.0",
"reading-time": "^1.5.0",
"sharp": "0.35.5",
"tailwindcss": "^4.1.10"
}
}
+4 -3
View File
@@ -41,6 +41,9 @@ importers:
reading-time:
specifier: ^1.5.0
version: 1.5.0
sharp:
specifier: ^0.35.5
version: 0.35.5(@types/node@24.13.5)
tailwindcss:
specifier: ^4.1.10
version: 4.3.0
@@ -2400,8 +2403,7 @@ snapshots:
'@fontsource/inter@5.2.8': {}
'@img/colour@1.1.0':
optional: true
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.35.5':
optionalDependencies:
@@ -4210,7 +4212,6 @@ snapshots:
'@img/sharp-win32-ia32': 0.35.5
'@img/sharp-win32-x64': 0.35.5
'@types/node': 24.13.5
optional: true
shiki@4.4.3:
dependencies:
+5
View File
@@ -7,3 +7,8 @@
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()
Strict-Transport-Security: max-age=31536000; includeSubDomains
# Built assets carry a content hash in their name, so a new build is a new
# URL and the old one can be cached for good.
/_astro/*
Cache-Control: public, max-age=31536000, immutable
Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

+47
View File
@@ -0,0 +1,47 @@
---
/**
* A background photo from src/assets/textures, built into AVIF (WebP for the
* few browsers without it) at a few widths, so every screen downloads only
* what it draws. Quality 50 keeps the grain of these photos at a fraction of
* WebP's size. A ~300-byte blurred copy is inlined as the image's own
* background, so the frame is never empty while the real file arrives.
*/
import { Picture } from 'astro:assets';
import sharp from 'sharp';
import path from 'node:path';
interface Props {
name: string;
/** The rendered width at each breakpoint, as an <img sizes> list. */
sizes: string;
/** Above the fold: load first instead of lazily. */
priority?: boolean;
class?: string;
}
const { name, sizes, priority = false, class: className = '' } = Astro.props;
const images = import.meta.glob<{ default: ImageMetadata }>('../assets/textures/*.webp', { eager: true });
const image = images[`../assets/textures/${name}.webp`]?.default;
if (!image) throw new Error(`Photo: no texture named "${name}" in src/assets/textures`);
const lqip = await sharp(path.join(process.cwd(), 'src/assets/textures', `${name}.webp`))
.resize(24)
.webp({ quality: 50 })
.toBuffer();
const placeholder = `background-image:url(data:image/webp;base64,${lqip.toString('base64')});background-size:cover;background-position:center;`;
---
<Picture
src={image}
formats={['avif']}
fallbackFormat="webp"
widths={[400, 640, 960, image.width]}
sizes={sizes}
quality={50}
alt=""
aria-hidden="true"
loading={priority ? 'eager' : 'lazy'}
fetchpriority={priority ? 'high' : 'auto'}
decoding="async"
class={className}
style={placeholder}
/>
+10 -4
View File
@@ -8,17 +8,23 @@
* (`.is-live`), `[data-tick]` numbers count up while live, and `enter-*`
* pieces play once, the first time it scrolls in (`.is-seen`).
*/
import Photo from './Photo.astro';
interface Props {
w: number;
h: number;
/** Background photo under /textures. */
photo: string;
/** Background photo in src/assets/textures; without one the stage is a plain grey frame. */
photo?: string;
class?: string;
}
const { w, h, photo, class: className = '' } = Astro.props;
// Wide scenes fill half the page at md+, the small bento ones a third at lg.
const sizes = w >= 600
? '(min-width: 768px) 50vw, 100vw'
: '(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw';
---
<div class={`stage-box group/stage relative w-full overflow-hidden rounded-[6px] bg-slate-100 ${className}`} style={`aspect-ratio:${w}/${h};`}>
<img src={`/textures/${photo}.webp`} alt="" aria-hidden="true" loading="lazy" decoding="async" class="absolute inset-0 h-full w-full object-cover select-none pointer-events-none transition-transform duration-[1200ms] ease-out group-hover/stage:scale-[1.03]" />
<div class={`stage-box group/stage relative w-full overflow-hidden rounded-[6px] ${photo ? 'bg-slate-100' : 'bg-slate-50 ring-1 ring-inset ring-slate-200'} ${className}`} style={`aspect-ratio:${w}/${h};`}>
{photo && <Photo name={photo} sizes={sizes} class="absolute inset-0 h-full w-full object-cover select-none pointer-events-none transition-transform duration-[1200ms] ease-out group-hover/stage:scale-[1.03]" />}
<div class="stage absolute left-0 top-0 origin-top-left" style={`width:${w}px;height:${h}px;transform:scale(var(--k,1));`} data-w={w} aria-hidden="true">
<slot />
</div>