Files
astro-website/openspec/changes/reduce-bounce-rate/design.md
Santhosh Janardhanan ac3de3e142
Some checks failed
ci / site (push) Has been cancelled
publish-image / publish (push) Has been cancelled
lazy-loading done
2026-02-10 15:59:03 -05:00

108 lines
9.5 KiB
Markdown

## 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.