## Context

The site is a static Astro SSG with no framework islands — all client-side interactivity uses vanilla JS via `<script is:inline>` tags. Images appear in two surfaces:

1. **Card thumbnails** — `StandardCard.astro` renders `<img src={imageUrl} alt="" loading="lazy" />` inside a `.card-media` div. When there's no image, a static `.card-placeholder` div fills the space. The image area is fixed at 180px height (200px on mobile).
2. **Blog featured images** — `blog/post/[slug].astro` and `blog/page/[slug].astro` render `<img loading="lazy" />` inline with a max-height of 420px.

Currently `loading="lazy"` defers fetching until the image nears the viewport, but the image area is blank/transparent until download completes, causing a jarring pop-in.

The site's dark theme (`--bg0: #0b1020`) means the shimmer must be a subtle light-on-dark gradient sweep — not a grey-on-white pattern.

## Goals / Non-Goals

**Goals:**
- Show an animated shimmer placeholder while images load, matching the site's dark aesthetic
- Fade in the actual image smoothly once loaded
- Handle image load errors gracefully (keep placeholder visible)
- Respect `prefers-reduced-motion` (suppress shimmer animation)
- Zero new dependencies — CSS keyframes + vanilla JS only

**Non-Goals:**
- Generating low-quality image placeholders (LQIP/BlurHash) at build time — this requires a build pipeline change and image processing dependency
- Lazy loading changes to the `loading` attribute strategy — `loading="lazy"` is already set and works correctly
- Image format optimization (WebP/AVIF conversion) — separate concern
- Placeholder for Instagram embeds — those use a third-party embed script with its own loading state

## Decisions

### 1. CSS shimmer via `@keyframes` on a pseudo-element

**Decision:** The shimmer effect uses a CSS `@keyframes` animation on a `::before` pseudo-element of the image wrapper. The pseudo-element displays a translucent gradient that sweeps left-to-right.

**Rationale:** Pure CSS, no JS needed for the animation. Pseudo-elements avoid extra DOM nodes. The existing `.card-placeholder` already fills the image area — the shimmer just adds motion on top of it.

**Alternatives considered:**
- SVG animated placeholder: More complex markup, no visual benefit for a simple shimmer.
- JS-driven animation (`requestAnimationFrame`): Heavier, no benefit over CSS keyframes.
- `<canvas>` BlurHash: Requires a build-time hash generation pipeline — overkill for a shimmer.

### 2. Image starts hidden, fades in on `load` event

**Decision:** The `<img>` element renders with `opacity: 0` (via a CSS class like `.img-loading`). A small inline `<script>` listens for the `load` event on each image and removes the loading class, triggering a CSS `opacity` transition to fade in. The shimmer placeholder sits behind the image and is naturally hidden once the image becomes opaque.

**Rationale:** The `load` event is the reliable signal that the image is ready to display. `opacity` transition is GPU-composited and smooth. No layout shift because the image dimensions are fixed by the container (`.card-media img` has `height: 180px; object-fit: cover`).

**Implementation detail:** Images may already be cached by the browser (instant load). The script must handle the case where `img.complete && img.naturalWidth > 0` on page load — immediately remove the loading class without waiting for the `load` event.

**Alternatives considered:**
- `IntersectionObserver` to defer `src` assignment: Already handled by `loading="lazy"` — doubling up adds complexity for no benefit.
- CSS-only `:not([src])` or `content-visibility`: No reliable CSS-only way to detect "image has loaded."

### 3. Single reusable wrapper pattern

**Decision:** Wrap each `<img>` in a container element with class `.img-shimmer-wrap`. This wrapper gets the shimmer pseudo-element and positions the image on top. The same wrapper pattern is used for both card thumbnails and blog featured images.

**Rationale:** One CSS class, one script, works everywhere. The wrapper inherits the size from its parent (`.card-media` for cards, inline styles for blog images), so no per-surface sizing logic is needed.

### 4. Error handling: keep placeholder visible

**Decision:** On image `error` event, the loading class stays on (image remains `opacity: 0`), so the shimmer/placeholder remains visible. The shimmer animation stops (replaced by a static placeholder state) to avoid an endlessly-animating error state.

**Rationale:** A static placeholder is better UX than a broken image icon or an infinite shimmer. The user sees a clean grey block instead of a broken experience.

### 5. Reduced motion: static placeholder, no shimmer sweep

**Decision:** The existing global CSS rule (`@media (prefers-reduced-motion: reduce)`) already suppresses all animations and transitions to near-zero duration. The shimmer animation inherits this behavior automatically — no additional media query needed.

**Rationale:** The global rule (`animation-duration: 0.001ms !important`) already covers all keyframe animations. The shimmer becomes a static gradient overlay, which is still a valid loading indicator without motion.

## Risks / Trade-offs

- **[Layout shift on blog images]** → Blog featured images use inline `max-height: 420px` but no explicit `width`/`height` attributes, which could cause minor CLS. Mitigation: the wrapper inherits `width: 100%` and `max-height: 420px` from the existing inline styles, so the placeholder reserves the correct space. No change in CLS from current behavior.
- **[Already-cached images flash shimmer]** → If images are in the browser cache, they load near-instantly, but the shimmer might flicker briefly. Mitigation: the script checks `img.complete` on DOM ready and immediately reveals cached images — no visible shimmer for cached images.
- **[Increased CSS size]** → The shimmer keyframes and wrapper styles add ~30 lines to `global.css`. Mitigation: negligible impact on a static site with a single CSS file.
