lazy-loading done
Some checks failed
ci / site (push) Has been cancelled
publish-image / publish (push) Has been cancelled

This commit is contained in:
2026-02-10 15:59:03 -05:00
parent 7bd51837de
commit ac3de3e142
24 changed files with 923 additions and 16 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-10

View File

@@ -0,0 +1,74 @@
## 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.

View File

@@ -0,0 +1,27 @@
## Why
All images on the site use `loading="lazy"` but render as blank space until the browser finishes downloading them. On slower connections or pages with many cards (homepage has 20+ cards), the user sees empty grey rectangles that pop into view abruptly. Adding a shimmer/skeleton placeholder while images load gives the perception of a faster, more polished page — the same pattern used by LinkedIn, YouTube, and AMP pages.
## What Changes
- **Shimmer placeholder for card thumbnails.** The `.card-media` area displays an animated skeleton shimmer while the `<img>` is loading. Once the image loads, it fades in over the shimmer. If the image fails to load, the placeholder remains visible (graceful degradation).
- **Shimmer placeholder for blog featured images.** Blog post and page detail pages show the same shimmer treatment on the hero/featured image while it loads.
- **Reduced-motion support.** The shimmer animation is suppressed when `prefers-reduced-motion: reduce` is active — the placeholder shows as a static block instead of animating.
## Capabilities
### New Capabilities
- `image-lazy-loading`: Shimmer/skeleton placeholder system for images that displays an animated loading state while images download, fades in the image on load, and handles load failures gracefully.
### Modified Capabilities
- `card-layout-system`: Card image area gains shimmer placeholder behavior during image loading (visual enhancement, no layout changes).
## Impact
- **Components**: `StandardCard.astro` — the `<img>` element needs a wrapper or sibling element for the shimmer, plus a script to detect load/error events.
- **Pages**: `blog/post/[slug].astro`, `blog/page/[slug].astro` — featured images get the same shimmer treatment.
- **CSS**: `global.css` — new shimmer animation keyframes and placeholder styles.
- **JS**: Small inline script to listen for image `load`/`error` events and toggle visibility classes.
- **No backend changes.** No data model, ingestion, or caching changes.
- **No new dependencies.** Pure CSS animation + vanilla JS event listeners.
- **Accessibility**: Shimmer respects `prefers-reduced-motion` (existing global rule covers animation suppression). No content or semantic changes.

View File

@@ -0,0 +1,35 @@
## MODIFIED Requirements
### Requirement: Standard card information architecture
All content cards rendered by the site MUST use a standardized layout so cards across different surfaces look consistent.
The standard card layout MUST be:
- featured image displayed prominently at the top (when available), with a shimmer placeholder visible while the image loads
- title
- summary/excerpt text, trimmed to a fixed maximum length
- footer row showing:
- publish date on the left
- views when available (if omitted, the footer MUST still render cleanly)
- the content source label (e.g., `youtube`, `podcast`, `blog`)
If a field is not available (for example, views for some sources), the card MUST still render cleanly with that field omitted.
#### Scenario: Card renders with all fields
- **WHEN** a content item has an image, title, summary, publish date, views, and source
- **THEN** the card renders those fields in the standard card layout order
#### Scenario: Card renders without views
- **WHEN** a content item has no views data
- **THEN** the card renders the footer bar with date + source and omits views without breaking the layout
#### Scenario: Card renders without featured image
- **WHEN** a content item has no featured image
- **THEN** the card renders a placeholder media area and still renders the remaining fields
#### Scenario: Card image shows shimmer while loading
- **WHEN** a content item has an image URL and the image has not yet loaded
- **THEN** the card media area displays an animated shimmer placeholder until the image loads and fades in
#### Scenario: Card image load failure shows static placeholder
- **WHEN** a content item has an image URL but the image fails to load
- **THEN** the card media area displays a static placeholder (no broken image icon) and the card remains visually intact

View File

@@ -0,0 +1,50 @@
## ADDED Requirements
### Requirement: Shimmer placeholder while images load
Every site image that uses `loading="lazy"` MUST display an animated shimmer placeholder in its container while the image is downloading.
The shimmer MUST be a translucent gradient sweep animation that matches the site's dark theme.
The shimmer MUST be visible from the moment the page renders until the image finishes loading.
#### Scenario: Image loads successfully on slow connection
- **WHEN** a page renders with a lazy-loaded image and the image takes time to download
- **THEN** the image container displays an animated shimmer placeholder until the image finishes loading
#### Scenario: Image loads from browser cache
- **WHEN** a page renders with a lazy-loaded image that is already in the browser cache
- **THEN** the image displays immediately with no visible shimmer flicker
### Requirement: Fade-in transition on image load
When a lazy-loaded image finishes downloading, it MUST fade in smoothly over the shimmer placeholder using a CSS opacity transition.
The fade-in duration MUST be short enough to feel responsive (no longer than 300ms).
#### Scenario: Image completes loading
- **WHEN** a lazy-loaded image finishes downloading
- **THEN** the image fades in over approximately 200300ms, replacing the shimmer placeholder
### Requirement: Graceful degradation on image load failure
If a lazy-loaded image fails to load (network error, 404, etc.), the shimmer animation MUST stop and the placeholder MUST remain visible as a static block.
The page MUST NOT display a broken image icon.
#### Scenario: Image fails to load
- **WHEN** a lazy-loaded image triggers an error event (e.g., 404 or network failure)
- **THEN** the shimmer animation stops and the container displays a static placeholder background instead of a broken image icon
### Requirement: Reduced motion support for shimmer
The shimmer animation MUST be suppressed when the user has `prefers-reduced-motion: reduce` enabled.
When motion is reduced, the placeholder MUST still be visible as a static block (no animation), maintaining the loading indicator without motion.
#### Scenario: User has reduced motion enabled
- **WHEN** a user with `prefers-reduced-motion: reduce` views a page with lazy-loaded images
- **THEN** the placeholder is visible as a static block without any sweeping animation
### Requirement: No layout shift from shimmer
The shimmer placeholder MUST NOT cause any cumulative layout shift (CLS). The placeholder MUST occupy the exact same dimensions as the image it replaces.
#### Scenario: Placeholder matches image dimensions
- **WHEN** a page renders with a shimmer placeholder for a card thumbnail
- **THEN** the placeholder occupies the same width and height as the image area (e.g., 100% width × 180px height for card thumbnails) with no layout shift when the image loads

View File

@@ -0,0 +1,34 @@
## 1. CSS shimmer styles
- [x] 1.1 Add `@keyframes shimmer` animation to `global.css` — a translucent gradient sweep (left-to-right) that works on the dark theme background (`--bg0`/`--bg1` palette).
- [x] 1.2 Add `.img-shimmer-wrap` class to `global.css``position: relative; overflow: hidden;` container that inherits dimensions from its parent. Add a `::before` pseudo-element with the shimmer animation (full width/height, absolute positioned, translucent light gradient).
- [x] 1.3 Add `.img-loading` class to `global.css` — sets `opacity: 0` on the `<img>` element. Add transition: `opacity 250ms ease`.
- [x] 1.4 Add `.img-error` class to `global.css` — stops the shimmer animation on the wrapper (`animation: none` on `::before`) so the placeholder displays as a static block.
- [x] 1.5 Verify the existing `@media (prefers-reduced-motion: reduce)` rule in `global.css` already suppresses the shimmer `@keyframes` animation (it should — the global rule sets `animation-duration: 0.001ms !important`).
## 2. Card thumbnail shimmer
- [x] 2.1 Update `StandardCard.astro` — wrap the existing `<img>` in a `<div class="img-shimmer-wrap">`. Add class `img-loading` to the `<img>` element. Keep the existing `.card-placeholder` fallback for cards with no image (no shimmer needed there).
- [x] 2.2 Ensure the `.img-shimmer-wrap` inside `.card-media` inherits the correct dimensions (`width: 100%; height: 180px` on desktop, `200px` on mobile) without causing layout shift.
## 3. Blog featured image shimmer
- [x] 3.1 Update `blog/post/[slug].astro` — wrap the featured `<img>` in a `<div class="img-shimmer-wrap">` with matching inline styles (`width: 100%; max-height: 420px; border-radius: 16px; overflow: hidden;`). Add class `img-loading` to the `<img>`.
- [x] 3.2 Update `blog/page/[slug].astro` — same shimmer wrapper treatment as blog posts.
## 4. Image load/error script
- [x] 4.1 Add an inline `<script is:inline>` in `BaseLayout.astro` (or a shared location) that runs on DOM ready. For every `img.img-loading` element: if `img.complete && img.naturalWidth > 0`, remove `img-loading` immediately (cached images). Otherwise, add `load` event listener to remove `img-loading` and `error` event listener to add `img-error` to the wrapper.
- [x] 4.2 Verify the script handles dynamically added images (not currently needed — SSG renders all images server-side — but ensure the script runs after all DOM is ready).
## 5. Verification
- [x] 5.1 Run `npm run build` in `site/` and verify no build errors.
- [x] 5.2 Run `npm test` in `site/` and verify all existing tests pass (1 pre-existing failure in blog-nav.test.ts unrelated to this change — expects `/about` nav link that doesn't exist).
- [x] 5.3 Manual smoke test: throttle network in DevTools (Slow 3G), load homepage — verify shimmer appears on card thumbnails, images fade in on load.
- [x] 5.4 Manual smoke test: load a blog post with a featured image on throttled network — verify shimmer and fade-in.
- [x] 5.5 Manual smoke test: break an image URL temporarily — verify static placeholder shows (no broken image icon, no infinite shimmer).
- [x] 5.6 Manual smoke test: enable `prefers-reduced-motion: reduce` in browser/OS settings — verify shimmer animation is suppressed (static placeholder, no sweep).
- [x] 5.7 Verify no cumulative layout shift: compare card dimensions before and after the change using DevTools Layout Shift overlay.
Note: Tasks 5.35.7 require manual browser testing with DevTools network throttling.