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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 296 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 395 KiB |
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
|
||||