lazy-loading done
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-10
|
||||
74
openspec/changes/archive/2026-02-10-lazy-loading/design.md
Normal file
74
openspec/changes/archive/2026-02-10-lazy-loading/design.md
Normal 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.
|
||||
27
openspec/changes/archive/2026-02-10-lazy-loading/proposal.md
Normal file
27
openspec/changes/archive/2026-02-10-lazy-loading/proposal.md
Normal 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.
|
||||
@@ -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
|
||||
@@ -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 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 × 180px height for card thumbnails) with no layout shift when the image loads
|
||||
34
openspec/changes/archive/2026-02-10-lazy-loading/tasks.md
Normal file
34
openspec/changes/archive/2026-02-10-lazy-loading/tasks.md
Normal 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.3–5.7 require manual browser testing with DevTools network throttling.
|
||||
2
openspec/changes/reduce-bounce-rate/.openspec.yaml
Normal file
2
openspec/changes/reduce-bounce-rate/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-10
|
||||
107
openspec/changes/reduce-bounce-rate/design.md
Normal file
107
openspec/changes/reduce-bounce-rate/design.md
Normal 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.
|
||||
36
openspec/changes/reduce-bounce-rate/proposal.md
Normal file
36
openspec/changes/reduce-bounce-rate/proposal.md
Normal 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.
|
||||
@@ -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`
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
51
openspec/changes/reduce-bounce-rate/tasks.md
Normal file
51
openspec/changes/reduce-bounce-rate/tasks.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
54
openspec/specs/image-lazy-loading/spec.md
Normal file
54
openspec/specs/image-lazy-loading/spec.md
Normal 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
|
||||
Reference in New Issue
Block a user