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.

View File

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

View File

@@ -0,0 +1,107 @@
## Context
The site is a static Astro site (SSG) with no framework islands — all client-side interactivity uses vanilla JS via `<script is:inline>` tags. The existing design system is a dark-theme glassmorphism palette (CSS custom properties in `global.css`). Video and podcast content is rendered through `ContentCard.astro``StandardCard.astro`, which currently produce `<a>` tags linking directly to YouTube/Spotify (`target="_blank"`). There are no existing modal/dialog components in the codebase.
Content data comes from a pre-built JSON cache (`ContentItem` type) that already contains `id`, `source`, `url`, `title`, `summary`, `publishedAt`, `thumbnailUrl`, and optional `metrics.views`. YouTube video IDs and Spotify episode IDs are derivable from the existing `url` field.
Umami analytics uses declarative `data-umami-event-*` attributes on HTML elements — no imperative `umami.track()` calls. The click tracking taxonomy defines event names (`click`, `cta_click`, `outbound_click`) and required properties (`target_id`, `placement`, `target_url`, etc.).
## Goals / Non-Goals
**Goals:**
- Keep users on-site when they click video/podcast content cards by opening an embedded player in a modal instead of navigating to YouTube/Spotify
- Stop media playback reliably when the modal is dismissed (close button, Escape key, backdrop click)
- Maintain the existing card visual layout — only the interaction target changes
- Track modal interactions (open, close, CTA clicks) via Umami using the existing `data-umami-event-*` attribute pattern
- Maintain WCAG 2.2 AA accessibility (focus trap, keyboard support, aria attributes)
**Non-Goals:**
- Changing the detail pages (`/videos/[id]`, `/podcast/[id]`) — they remain as-is for SEO/sharing
- Introducing a JS framework (React/Preact/Svelte) — the modal uses vanilla JS consistent with the rest of the project
- Instagram card behavior — Instagram cards are unaffected (they already use a different embed approach)
- Live view count fetching from YouTube API at modal-open time — views come from the pre-built cache. Live counts are a future enhancement
- Picture-in-picture or mini-player functionality
- Autoplay — the user must initiate playback within the embed
## Decisions
### 1. Use native `<dialog>` element for the modal
**Decision:** Use the HTML `<dialog>` element with `showModal()` / `close()`.
**Rationale:** The native `<dialog>` provides built-in backdrop, focus trapping, Escape-to-close, and `aria-modal` semantics for free. No framework dependency needed, consistent with the project's vanilla-JS approach. Browser support is universal in evergreen browsers.
**Alternatives considered:**
- Custom `<div>` with manual focus trap + backdrop: More code, more accessibility bugs, no benefit.
- Astro island with React/Preact: Adds a build dependency and hydration cost for a single component. Overkill for a modal.
### 2. Destroy iframe on close to stop playback
**Decision:** When the modal closes, remove the iframe `src` (set to `about:blank` or remove the iframe entirely) rather than using the YouTube IFrame API or Spotify SDK to pause.
**Rationale:** Both YouTube and Spotify embeds load third-party scripts that are outside our control. The YouTube IFrame API requires loading `https://www.youtube.com/iframe_api` and managing a `YT.Player` instance. Spotify has no public embed API for programmatic pause. Removing/blanking the iframe src is the simplest, most reliable way to guarantee playback stops — no race conditions, no API loading, works for both platforms identically.
**Alternatives considered:**
- YouTube IFrame API `player.stopVideo()`: Requires async API load, player ready callbacks, and doesn't work for Spotify. Two different code paths for two platforms.
- `postMessage` to iframe: Relies on undocumented/unstable message formats per platform. Fragile.
### 3. Cards become `<button>` triggers instead of `<a>` links
**Decision:** For video/podcast content cards, change the root element from `<a href="..." target="_blank">` to a `<button>` (or a clickable `<div>` with `role="button" tabindex="0"`) that opens the modal. The card visual stays the same.
**Rationale:** The card no longer navigates anywhere — it opens a modal. Semantically, this is a button action, not a link. Using a `<button>` (or button role) is correct for accessibility and avoids the confusion of an `<a>` that doesn't navigate.
**Implementation note:** `StandardCard.astro` currently renders an `<a>`. A new prop (e.g., `as="button"`) or a separate `StandardCardButton.astro` wrapper can handle this. The card must carry the `ContentItem` data as `data-*` attributes so the modal script can read them on click.
**Alternatives considered:**
- Keep as `<a>` with `href="#"` and `preventDefault`: Semantically incorrect, accessibility tools may announce it as a link.
- Wrap card in both `<a>` (for SEO/no-JS fallback) and add JS override: Adds complexity. The detail pages already serve the SEO purpose.
### 4. Data flow via `data-*` attributes on card elements
**Decision:** Cards embed the `ContentItem` data they need (id, source, url, title, summary, publishedAt, thumbnailUrl, views) as `data-*` attributes on the card element. The modal script reads these attributes on click to populate the modal.
**Rationale:** The site is SSG — no runtime data store or state management. `data-*` attributes are the standard Astro pattern for passing server-rendered data to inline scripts (same approach used for nav toggle with `data-nav-toggle`, `data-open`). No serialization overhead, no global state, works with any number of cards.
**Alternatives considered:**
- Global JSON blob in a `<script>` tag: Works but couples the modal to a specific data shape and page. `data-*` is more composable.
- Re-fetch from cache at runtime: The site is static. There's no runtime API to call.
### 5. Embed URL construction
**Decision:**
- **YouTube:** Extract video ID from `item.url` (pattern: `youtube.com/watch?v={id}`) → embed URL: `https://www.youtube.com/embed/{id}?rel=0&modestbranding=1`
- **Spotify/Podcast:** Extract episode path from `item.url` (pattern varies by podcast host) → embed URL: `https://open.spotify.com/embed/episode/{id}?theme=0` (dark theme). If the podcast URL is not a Spotify URL, fall back to a "Listen on platform" link instead of an embed.
**Rationale:** YouTube embed URLs have a stable, well-documented format. Spotify embed works for episodes hosted on Spotify. For non-Spotify podcast URLs, embedding isn't universally possible, so the modal gracefully degrades to showing metadata + a link.
### 6. Umami event taxonomy for modal interactions
**Decision:** Introduce a new event name `media_preview` for card clicks that open the modal (replacing `outbound_click`). Add distinct events for modal interactions:
| Interaction | Event Name | Key Properties |
|---|---|---|
| Card click → modal opens | `media_preview` | `target_id`, `placement`, `title`, `type`, `source` |
| Modal close (X / Escape / backdrop) | `media_preview_close` | `target_id`, `close_method` (`button` / `escape` / `backdrop`) |
| "View on YouTube/Spotify" CTA | `cta_click` | `target_id`, `placement=media_modal`, `platform`, `target_url` |
| "Follow on YouTube/Spotify" CTA | `cta_click` | `target_id`, `placement=media_modal`, `platform`, `target_url` |
**Rationale:** `outbound_click` is no longer accurate — the user isn't leaving the site. `media_preview` describes what actually happens. Close-method tracking (`close_method` property) allows measuring whether users engage with the content or immediately dismiss. CTA clicks inside the modal reuse the existing `cta_click` event name with `placement=media_modal` for consistency with the CTA tracking taxonomy.
### 7. Modal layout mirrors the card but expanded
**Decision:** The modal content area uses the same information architecture as the card (image/embed → title → summary → footer) but expanded:
1. **Header row:** Title (left) + Close button (right)
2. **Embed area:** YouTube iframe or Spotify embed (16:9 aspect ratio for video, fixed height for podcast)
3. **Body:** Full description/summary (not truncated), date, view count
4. **Footer CTAs:** "Follow on YouTube/Spotify" + "View on YouTube/Spotify"
**Rationale:** This mirrors the user's request for "an extended version of the card layout." The familiar structure reduces cognitive load.
## Risks / Trade-offs
- **[Spotify embed availability]** → Not all podcast episodes have Spotify URLs. Mitigation: if `item.url` is not a Spotify URL, the modal shows metadata + a "Listen on [platform]" outbound link instead of an embed.
- **[Third-party embed loading time]** → YouTube/Spotify iframes add load time. Mitigation: iframes are only loaded when the modal opens (lazy). A loading placeholder is shown until the iframe loads.
- **[No-JS fallback]** → If JavaScript is disabled, the `<button>` cards won't open a modal. Mitigation: Include a `<noscript>` hint or make the card a link as a no-JS fallback (progressive enhancement). The detail pages (`/videos/[id]`, `/podcast/[id]`) always exist as a non-JS path.
- **[Content Security Policy]** → Embedding YouTube/Spotify requires `frame-src` to allow these domains. Mitigation: verify CSP headers (if any) allow `youtube.com` and `spotify.com` iframe sources. Currently no CSP is set, so no issue.
- **[Modal on mobile]** → Full-screen modal on small viewports may feel heavy. Mitigation: on mobile breakpoints, the modal takes full viewport width with reduced padding, and the embed scales responsively via `aspect-ratio` or percentage-based sizing.

View File

@@ -0,0 +1,36 @@
## Why
Content cards on the homepage, videos page, and podcast page currently link directly to YouTube/Spotify as outbound links (`target="_blank"`). Every click immediately sends the user off-site, inflating bounce rate and cutting short time-on-site. Embedding media in an in-page modal keeps users on the site longer while still providing clear CTAs to the canonical platform when they're ready.
## What Changes
- **Content cards become modal triggers instead of outbound links.** Clicking a video or podcast card on the homepage, `/videos`, or `/podcast` opens a modal dialog with an embedded player — the user stays on-site.
- **New modal dialog component.** An accessible modal overlay that renders:
- Title (top-left) and a close button (top-right)
- Embedded YouTube iframe or Spotify embed player (based on content source)
- Description / summary text
- Date and view count (live count if YouTube API key is configured)
- "Follow on YouTube/Spotify" CTA and "View on YouTube/Spotify" CTA
- **Playback lifecycle tied to modal.** Closing the modal (close button or `Escape` key) MUST stop media playback — no audio/video continues after the modal is dismissed.
- **Umami event taxonomy update.** Card clicks on listing pages are no longer outbound — event names change from `outbound_click` to a new interaction event. New events are added for modal open, modal close, follow CTA, view-on-platform CTA, and (stretch) engagement duration.
## Capabilities
### New Capabilities
- `media-modal`: Accessible modal dialog component that embeds YouTube/Spotify players, displays content metadata, provides platform CTAs, and manages playback lifecycle (stop on close/escape).
### Modified Capabilities
- `card-layout-system`: Cards for video/podcast content become modal triggers instead of outbound `<a>` links. The card visual layout stays the same; the interaction target changes.
- `interaction-tracking-taxonomy`: Card clicks are no longer `outbound_click` — a new event category is needed for in-page media previews. New tracked interactions: modal open, modal close, modal CTA clicks (follow, view-on-platform). Stretch: engagement duration tracking.
- `analytics-umami`: New event names and properties must be emitted for modal interactions using the existing `data-umami-event-*` attribute pattern.
- `conversion-ctas`: New CTA instances inside the modal ("Follow on YouTube/Spotify", "View on YouTube/Spotify") must follow the existing `CtaLink` tracking conventions.
## Impact
- **Components**: `ContentCard.astro`, `StandardCard.astro` — card click behavior changes from outbound link to modal trigger on video/podcast sources (Instagram cards are unaffected).
- **New component**: A `MediaModal` component (likely client-side JS/Astro island) for the dialog, embed player, and playback control.
- **Pages**: `index.astro`, `videos.astro`, `podcast.astro` — must mount the modal and wire card click handlers.
- **Analytics**: All `data-umami-event="outbound_click"` on video/podcast content cards change to a non-outbound event. New events added for modal interactions.
- **Accessibility**: Modal must follow WCAG 2.2 AA patterns already established in `wcag-responsive-ui` spec — focus trap, `Escape` to close, `aria-modal`, focus return on dismiss.
- **No backend changes.** Content ingestion, caching, and data model (`ContentItem`) are unchanged. YouTube embed IDs are derivable from existing `item.url`. Spotify embed URLs are derivable from existing `item.url`.
- **No breaking changes.** Detail pages (`/videos/[id]`, `/podcast/[id]`) remain as-is for SEO/sharing. The modal is an additive UX layer on listing surfaces only.

View File

@@ -0,0 +1,51 @@
## MODIFIED Requirements
### Requirement: Custom event tracking
When Umami is enabled, the site MUST support custom event emission for:
- `cta_click`
- `outbound_click`
- `media_preview`
- `media_preview_close`
- a general click interaction event for all instrumented clickable items (per the site tracking taxonomy)
Each emitted event MUST include enough properties to segment reports by platform and placement when applicable.
All tracked clickable items MUST emit events with a unique, consistent set of data elements as defined by the site tracking taxonomy, including at minimum `target_id` and `placement`.
The site MUST instrument tracked clickables using Umami's supported Track Events data-attribute method:
- `data-umami-event="<event-name>"`
- optional event data using `data-umami-event-*`
For interactions that are triggered programmatically (e.g., modal close events where the close method must be recorded), the site MAY use Umami's JavaScript API (`umami.track()`) instead of data attributes when data attributes cannot express the required properties.
For content-related links (clickables representing a specific piece of content), the site MUST also provide the following Umami event data attributes:
- `data-umami-event-title`
- `data-umami-event-type`
#### Scenario: Emit outbound click event
- **WHEN** a user clicks a non-CTA outbound link from the homepage
- **THEN** the system emits an `outbound_click` event with a property identifying the destination domain
#### Scenario: Emit general click event for any clickable
- **WHEN** a user clicks an instrumented navigation link
- **THEN** the system emits a click interaction event with `target_id` and `placement`
#### Scenario: Content click includes title and type
- **WHEN** a user clicks an instrumented content link (video, podcast episode, blog post/page)
- **THEN** the emitted Umami event includes `title` and `type` properties via `data-umami-event-*` attributes
#### Scenario: Uninstrumented clicks do not break the page
- **WHEN** a user clicks an element with no tracking metadata
- **THEN** the system does not throw and navigation/interaction proceeds normally
#### Scenario: Media preview event emitted on card click
- **WHEN** a user clicks a video or podcast content card that opens a media modal
- **THEN** the system emits a `media_preview` event with `target_id`, `placement`, `title`, `type`, and `source`
#### Scenario: Media preview close event emitted
- **WHEN** a user closes the media modal
- **THEN** the system emits a `media_preview_close` event with the `target_id` of the content that was previewed and the `close_method` used
#### Scenario: Modal CTA click emitted
- **WHEN** a user clicks a CTA inside the media modal (e.g., "View on YouTube")
- **THEN** the system emits a `cta_click` event with `target_id`, `placement=media_modal`, `platform`, and `target_url`

View File

@@ -0,0 +1,45 @@
## 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)
- 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.
For content cards with source `youtube` or `podcast`, the card MUST render as a clickable element that opens a media modal dialog instead of navigating to an external URL. The card MUST NOT render as an outbound `<a>` link for these sources.
For content cards with other sources (e.g., `blog`, `instagram`), the card MUST continue to render as a navigational link (the existing behavior).
The card element for modal-trigger cards MUST carry the content item's data (id, source, url, title, summary, publishedAt, thumbnailUrl, views) as `data-*` attributes so the modal script can access them.
#### 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: YouTube video card opens modal
- **WHEN** a user clicks a content card with source `youtube`
- **THEN** a media modal dialog opens with the video's embedded player and metadata instead of navigating to YouTube
#### Scenario: Podcast card opens modal
- **WHEN** a user clicks a content card with source `podcast`
- **THEN** a media modal dialog opens with the episode's embedded player (or metadata link) instead of navigating to the podcast platform
#### Scenario: Blog card still navigates
- **WHEN** a user clicks a content card with source `blog`
- **THEN** the card navigates to the blog post as an internal link (existing behavior, unaffected)

View File

@@ -0,0 +1,36 @@
## ADDED Requirements
### Requirement: Modal CTAs for YouTube and Spotify
The media modal MUST render two CTA actions:
- "Follow on YouTube" / "Follow on Spotify" — links to the channel/podcast profile page
- "View on YouTube" / "View on Spotify" — links to the specific content item URL
The CTA label and destination MUST be determined by the content source:
- For `youtube` source: "Follow on YouTube" links to the YouTube channel URL, "View on YouTube" links to the video URL
- For `podcast` source: "Follow on Spotify" links to the podcast profile URL, "View on Spotify" links to the episode URL
Each CTA MUST be rendered using the existing `CtaLink` component conventions (or equivalent markup) with UTM parameters appended.
#### Scenario: YouTube video modal shows YouTube CTAs
- **WHEN** the media modal is displaying a YouTube video
- **THEN** the modal renders "Follow on YouTube" (linking to the channel) and "View on YouTube" (linking to the video URL) as CTA actions
#### Scenario: Podcast episode modal shows Spotify CTAs
- **WHEN** the media modal is displaying a podcast episode
- **THEN** the modal renders "Follow on Spotify" (linking to the podcast profile) and "View on Spotify" (linking to the episode URL) as CTA actions
### Requirement: Modal CTA tracking
Each CTA rendered inside the media modal MUST emit a `cta_click` event conforming to the existing CTA tracking requirements.
The modal CTAs MUST use:
- `placement=media_modal`
- `target_id` following the `modal.cta.{action}.{platform}` namespace
- `platform` set to `youtube` or `spotify` (mapped from content source)
#### Scenario: Modal CTA emits cta_click event
- **WHEN** a user clicks the "View on YouTube" CTA inside the media modal
- **THEN** the system emits a `cta_click` event with `target_id=modal.cta.view.youtube`, `placement=media_modal`, `platform=youtube`, and `target_url` set to the video URL
#### Scenario: Modal CTA includes UTM parameters
- **WHEN** a modal CTA is rendered
- **THEN** the CTA link URL includes UTM parameters for attribution (utm_source, utm_medium, utm_campaign, utm_content)

View File

@@ -0,0 +1,88 @@
## ADDED Requirements
### Requirement: Media preview event for in-page content views
The tracking taxonomy MUST define a `media_preview` event for content card clicks that open an in-page media modal instead of navigating outbound.
The `media_preview` event MUST include the following properties:
- `target_id` (stable unique identifier for the card, following the existing `card.{placement}.{source}.{id}` format)
- `placement` (the listing surface where the card appears, e.g., `home.newest`, `videos.list`, `podcast.list`)
- `title` (human-readable content title, truncated to max 160 characters)
- `type` (`video` or `podcast_episode`)
- `source` (`youtube` or `podcast`)
#### Scenario: Video card click emits media_preview
- **WHEN** a user clicks a YouTube video card on the videos listing page
- **THEN** the system emits a `media_preview` event with `target_id=card.videos.list.youtube.{id}`, `placement=videos.list`, `type=video`, and `source=youtube`
#### Scenario: Podcast card click emits media_preview on homepage
- **WHEN** a user clicks a podcast card in the homepage podcast section
- **THEN** the system emits a `media_preview` event with `target_id=card.home.podcast.podcast.{id}`, `placement=home.podcast`, `type=podcast_episode`, and `source=podcast`
### Requirement: Media preview close event
The tracking taxonomy MUST define a `media_preview_close` event emitted when the media modal is dismissed.
The `media_preview_close` event MUST include:
- `target_id` (the same identifier as the `media_preview` event that opened the modal)
- `close_method` (one of: `button`, `escape`, `backdrop`)
#### Scenario: User closes modal via Escape key
- **WHEN** the user presses Escape to close the media modal that was opened from a video card
- **THEN** the system emits a `media_preview_close` event with the original `target_id` and `close_method=escape`
#### Scenario: User closes modal via close button
- **WHEN** the user clicks the close button on the media modal
- **THEN** the system emits a `media_preview_close` event with the original `target_id` and `close_method=button`
#### Scenario: User closes modal via backdrop click
- **WHEN** the user clicks the backdrop outside the modal content
- **THEN** the system emits a `media_preview_close` event with the original `target_id` and `close_method=backdrop`
### Requirement: Modal CTA namespace
The tracking taxonomy MUST define a `media_modal` placement value for CTA interactions within the media modal.
Modal CTAs MUST use `target_id` values in the namespace `modal.cta.{action}.{platform}` where `action` is `follow` or `view` and `platform` is `youtube` or `spotify`.
#### Scenario: User clicks "View on YouTube" in modal
- **WHEN** the user clicks the "View on YouTube" CTA inside the media modal
- **THEN** the system emits a `cta_click` event with `target_id=modal.cta.view.youtube`, `placement=media_modal`, and `platform=youtube`
#### Scenario: User clicks "Follow on Spotify" in modal
- **WHEN** the user clicks the "Follow on Spotify" CTA inside the media modal
- **THEN** the system emits a `cta_click` event with `target_id=modal.cta.follow.spotify`, `placement=media_modal`, and `platform=spotify`
## MODIFIED Requirements
### Requirement: Minimum required properties
Every tracked click event MUST include, at minimum:
- `target_id`
- `placement`
For links, the event MUST also include:
- `target_url` (or a stable target identifier that can be mapped to a URL)
For content-related links (clickables representing a specific piece of content), the event MUST also include:
- `title` (human-readable content title)
- `type` (content type identifier)
The `type` value MUST be one of:
- `video`
- `podcast_episode`
- `blog_post`
- `blog_page`
For non-link clickables that trigger in-page actions (e.g., modal openers), the event MUST also include:
- `title` (human-readable content title)
- `type` (content type identifier)
- `source` (content source identifier)
#### Scenario: Tracking a content card click
- **WHEN** a user clicks a content card link
- **THEN** the emitted event includes `target_id`, `placement`, and `target_url`
#### Scenario: Tracking a content link includes title and type
- **WHEN** a user clicks a content-related link that represents a specific content item
- **THEN** the emitted event includes `target_id`, `placement`, `target_url`, `title`, and `type`
#### Scenario: Tracking a modal-trigger card includes title, type, and source
- **WHEN** a user clicks a content card that opens a media modal instead of navigating
- **THEN** the emitted event includes `target_id`, `placement`, `title`, `type`, and `source` (no `target_url` since the user stays on-page)

View File

@@ -0,0 +1,86 @@
## ADDED Requirements
### Requirement: Media modal dialog
The site MUST provide a modal dialog that displays embedded media (YouTube video or podcast episode) when a user clicks a video or podcast content card on a listing surface (homepage, `/videos`, `/podcast`).
The modal MUST render the following elements in order:
- A header row with the content title on the left and a close button on the right
- An embedded media player (YouTube iframe for videos, Spotify embed for podcast episodes when the URL is a Spotify URL)
- The full description/summary text (not truncated)
- The publish date and view count (when available)
- A "Follow on YouTube/Spotify" CTA and a "View on YouTube/Spotify" CTA
#### Scenario: User clicks a YouTube video card
- **WHEN** a user clicks a video content card on any listing surface
- **THEN** a modal dialog opens displaying a YouTube iframe embed, the video title, full description, date, view count (if available), and CTAs for "Follow on YouTube" and "View on YouTube"
#### Scenario: User clicks a podcast episode card with a Spotify URL
- **WHEN** a user clicks a podcast content card whose URL is a Spotify URL
- **THEN** a modal dialog opens displaying a Spotify episode embed, the episode title, full description, date, and CTAs for "Follow on Spotify" and "View on Spotify"
#### Scenario: User clicks a podcast episode card with a non-Spotify URL
- **WHEN** a user clicks a podcast content card whose URL is not a Spotify URL
- **THEN** the modal dialog opens displaying the episode metadata (title, description, date) and a "Listen on [platform]" outbound link instead of an embedded player
#### Scenario: Modal renders with missing optional fields
- **WHEN** a content item has no view count or no summary
- **THEN** the modal MUST still render cleanly with those fields omitted
### Requirement: Playback stops on modal close
The modal MUST stop all media playback when it is dismissed, regardless of the dismissal method.
The modal MUST support three dismissal methods:
- Close button click
- Pressing the `Escape` key
- Clicking the backdrop outside the modal content
After dismissal, no audio or video from the embedded player MUST continue playing.
#### Scenario: User closes modal via close button
- **WHEN** the modal is open with a playing YouTube video and the user clicks the close button
- **THEN** the modal closes and the video playback stops immediately
#### Scenario: User presses Escape while modal is open
- **WHEN** the modal is open with a playing Spotify episode and the user presses the `Escape` key
- **THEN** the modal closes and the audio playback stops immediately
#### Scenario: User clicks the backdrop
- **WHEN** the modal is open and the user clicks outside the modal content area (the backdrop)
- **THEN** the modal closes and any active media playback stops immediately
### Requirement: Modal accessibility
The modal MUST conform to WCAG 2.2 AA dialog patterns:
- The modal MUST use the native `<dialog>` element opened via `showModal()`
- The modal MUST trap keyboard focus within the dialog while open
- The modal MUST set `aria-modal="true"` and have an accessible label (via `aria-labelledby` referencing the title element)
- Closing the modal MUST return focus to the element that triggered it (the card that was clicked)
- The close button MUST have an accessible label (e.g., `aria-label="Close"`)
#### Scenario: Focus is trapped within the modal
- **WHEN** the modal is open and the user presses `Tab`
- **THEN** focus cycles through the focusable elements within the modal and does not move to elements behind the modal
#### Scenario: Focus returns to trigger on close
- **WHEN** the user closes the modal
- **THEN** focus returns to the card element that originally opened the modal
### Requirement: Responsive modal layout
The modal MUST be responsive across viewports:
- On desktop viewports, the modal MUST be centered with a max-width that leaves visible backdrop on both sides
- On mobile viewports (at or below the site's mobile breakpoint), the modal MUST expand to near-full viewport width with reduced padding
- Embedded media MUST scale proportionally (16:9 aspect ratio for YouTube video, fixed height for Spotify embed)
#### Scenario: Modal on desktop viewport
- **WHEN** the modal is opened on a desktop viewport
- **THEN** the modal is centered horizontally with backdrop visible and the video embed maintains a 16:9 aspect ratio
#### Scenario: Modal on mobile viewport
- **WHEN** the modal is opened on a mobile viewport
- **THEN** the modal expands to near-full viewport width and the video embed scales to fit
### Requirement: Embed loading state
The modal MUST display a loading placeholder while the embedded media iframe is loading.
#### Scenario: Iframe loading
- **WHEN** the modal opens and the iframe has not yet loaded
- **THEN** a placeholder (matching the site's card-placeholder style) is visible in the embed area until the iframe finishes loading

View File

@@ -0,0 +1,51 @@
## 1. Card component changes
- [ ] 1.1 Add a `mode` prop (or equivalent) to `StandardCard.astro` to support rendering as a `<button>` (modal trigger) instead of an `<a>` link. When `mode="modal"`, the root element MUST be a `<button>` (or `role="button" tabindex="0"`) with the same visual styling as the current card.
- [ ] 1.2 Add `data-*` attributes to modal-trigger cards in `ContentCard.astro` — encode `id`, `source`, `url`, `title`, `summary`, `publishedAt`, `thumbnailUrl`, and `metrics.views` so the modal script can read them on click.
- [ ] 1.3 Update `ContentCard.astro` to use `mode="modal"` for `youtube` and `podcast` sources, and keep `mode="link"` (current behavior) for other sources.
- [ ] 1.4 Change Umami event from `outbound_click` to `media_preview` on modal-trigger cards. Update `data-umami-event-*` attributes to include `target_id`, `placement`, `title`, `type`, and `source` (drop `domain`, `target_url` since it's no longer outbound).
## 2. Media modal component
- [ ] 2.1 Create `MediaModal.astro` component containing a native `<dialog>` element with the modal layout: header (title + close button), embed area, description, date/views row, CTA row.
- [ ] 2.2 Add CSS for the modal in `global.css`: backdrop styling, modal container, responsive layout (centered on desktop, near-full-width on mobile), embed aspect-ratio (16:9 for video), loading placeholder.
- [ ] 2.3 Write the modal open/populate script: listen for clicks on modal-trigger cards, read `data-*` attributes, populate the modal fields (title, description, date, views), construct the embed URL, set the iframe `src`, and call `dialog.showModal()`.
- [ ] 2.4 Implement embed URL construction: extract YouTube video ID from `item.url``https://www.youtube.com/embed/{id}?rel=0&modestbranding=1`. For podcast, detect Spotify URLs → `https://open.spotify.com/embed/episode/{id}?theme=0`. For non-Spotify podcast URLs, hide the embed area and show a "Listen on [platform]" link.
- [ ] 2.5 Implement playback stop on close: on dialog `close` event, set iframe `src` to `about:blank` (or remove the iframe) to guarantee playback stops.
- [ ] 2.6 Implement all three close methods: close button click, `Escape` key (native `<dialog>` handles this), and backdrop click (detect clicks on `<dialog>` element outside the inner content container).
## 3. Modal accessibility
- [ ] 3.1 Set `aria-modal="true"` and `aria-labelledby` (referencing the title element) on the `<dialog>`.
- [ ] 3.2 Add `aria-label="Close"` to the close button.
- [ ] 3.3 Implement focus return: store a reference to the clicked card before opening, restore focus to it on close.
- [ ] 3.4 Verify focus trapping works with the native `<dialog>` (Tab cycles through modal focusables only).
- [ ] 3.5 Verify `prefers-reduced-motion` suppresses modal open/close animations (covered by the existing global CSS rule).
## 4. Modal CTAs
- [ ] 4.1 Render "Follow on YouTube" / "Follow on Spotify" CTA inside the modal, linking to the channel/podcast profile URL (from `LINKS.youtubeChannel` / `LINKS.podcast`). Apply UTM parameters.
- [ ] 4.2 Render "View on YouTube" / "View on Spotify" CTA inside the modal, linking to the specific content item URL. Apply UTM parameters.
- [ ] 4.3 Add `data-umami-event="cta_click"` with `target_id=modal.cta.{action}.{platform}`, `placement=media_modal`, `platform`, and `target_url` on each modal CTA.
## 5. Umami analytics updates
- [ ] 5.1 Emit `media_preview_close` event on modal close with `target_id` (from the card that opened it) and `close_method` (`button`, `escape`, or `backdrop`). Use `umami.track()` JS API since `close_method` is determined at runtime.
- [ ] 5.2 Verify `media_preview` event fires correctly from card `data-umami-event` attributes on listing surfaces (homepage newest, homepage high-performing, homepage podcast, videos list, podcast list).
- [ ] 5.3 Update existing Umami attribute tests (`site/tests/umami-attributes.test.ts`) to expect `media_preview` instead of `outbound_click` for video/podcast cards.
- [ ] 5.4 Add new test cases for modal CTA Umami attributes (`cta_click`, `target_id`, `placement=media_modal`).
## 6. Page integration
- [ ] 6.1 Add the `MediaModal` component to `index.astro` (homepage).
- [ ] 6.2 Add the `MediaModal` component to `videos.astro`.
- [ ] 6.3 Add the `MediaModal` component to `podcast.astro`.
## 7. Verification
- [ ] 7.1 Run `npm run build` in `site/` and verify no build errors.
- [ ] 7.2 Run `npm test` in `site/` and verify all tests pass (including updated Umami attribute tests).
- [ ] 7.3 Manual smoke test: click a video card → modal opens with YouTube embed → close → playback stops. Repeat with Escape and backdrop click.
- [ ] 7.4 Manual smoke test: click a podcast card → modal opens with Spotify embed (or fallback link) → close → playback stops.
- [ ] 7.5 Verify modal accessibility: keyboard-only navigation, focus trap, focus return, screen reader announces dialog.
- [ ] 7.6 Verify responsive behavior: modal layout on desktop and mobile viewports.

View File

@@ -8,7 +8,7 @@ Define a standardized card layout so content cards across surfaces look consiste
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)
- 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:
@@ -30,3 +30,10 @@ If a field is not available (for example, views for some sources), the card MUST
- **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,54 @@
## Purpose
Define a consistent lazy-loading experience for images by showing a shimmer placeholder while images download, fading in images on load, and degrading gracefully on failures.
## 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 200-300ms, 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 x 180px height for card thumbnails) with no layout shift when the image loads