fix for SR
Some checks failed
ci / site (push) Has been cancelled
publish-image / publish (push) Has been cancelled

This commit is contained in:
2026-02-10 17:54:13 -05:00
parent 5d07e57256
commit 57ad560b01
18 changed files with 389 additions and 110 deletions

View File

@@ -2,9 +2,9 @@
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. 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. Content data comes from a pre-built JSON cache (`ContentItem` type) that contains `id`, `source`, `url`, `title`, `summary`, `publishedAt`, `thumbnailUrl`, and optional `metrics.views`. For podcasts, the cache may also include an `audioUrl` (from RSS enclosure) to enable a first-party audio fallback when a Spotify embed URL is not available.
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.). Umami analytics primarily uses declarative `data-umami-event-*` attributes on HTML elements. For interactions that need runtime-derived properties (e.g., modal close method), the implementation may use `umami.track()`.
## Goals / Non-Goals ## Goals / Non-Goals
@@ -71,10 +71,12 @@ Umami analytics uses declarative `data-umami-event-*` attributes on HTML element
**Decision:** **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` - **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. - **Spotify/Podcast:** When a Spotify episode ID is available, embed using `https://open.spotify.com/embed/episode/{id}` with Spotify's recommended iframe allowlist and sizing (fixed height ~232px). If a Spotify embed URL is not available, fall back to an in-modal HTML5 `<audio>` player when an `audioUrl` is present, otherwise show an outbound "Listen on Spotify" link.
**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. **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.
In practice, many podcast feeds provide a creator/profile URL (or a podcasters.spotify.com URL) plus an MP3 enclosure, but not the canonical `open.spotify.com/episode/{id}` URL needed for a Spotify embed. The `audioUrl` fallback keeps playback on-site in that case.
### 6. Umami event taxonomy for modal interactions ### 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: **Decision:** Introduce a new event name `media_preview` for card clicks that open the modal (replacing `outbound_click`). Add distinct events for modal interactions:
@@ -83,8 +85,8 @@ Umami analytics uses declarative `data-umami-event-*` attributes on HTML element
|---|---|---| |---|---|---|
| Card click → modal opens | `media_preview` | `target_id`, `placement`, `title`, `type`, `source` | | 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`) | | 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` | | "View on YouTube" / "Listen on 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` | | "Subscribe on YouTube" / "Follow on 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. **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.
@@ -94,13 +96,13 @@ Umami analytics uses declarative `data-umami-event-*` attributes on HTML element
1. **Header row:** Title (left) + Close button (right) 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) 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 3. **Body:** Full description/summary (not truncated), date, view count
4. **Footer CTAs:** "Follow on YouTube/Spotify" + "View on YouTube/Spotify" 4. **Footer CTAs:** "Subscribe on YouTube" / "Follow on Spotify" + "View on YouTube" / "Listen on Spotify"
**Rationale:** This mirrors the user's request for "an extended version of the card layout." The familiar structure reduces cognitive load. **Rationale:** This mirrors the user's request for "an extended version of the card layout." The familiar structure reduces cognitive load.
## Risks / Trade-offs ## 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. - **[Spotify embed availability]** → Not all podcast episodes have Spotify episode URLs. Mitigation: use first-party `<audio>` playback from RSS enclosure (`audioUrl`) when available; otherwise show a "Listen on Spotify" outbound link. Optional: maintain a small override map (`content/podcast-spotify-map.json`) to supply episode embed URLs when desired.
- **[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. - **[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. - **[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. - **[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.

View File

@@ -10,7 +10,7 @@ Content cards on the homepage, videos page, and podcast page currently link dire
- Embedded YouTube iframe or Spotify embed player (based on content source) - Embedded YouTube iframe or Spotify embed player (based on content source)
- Description / summary text - Description / summary text
- Date and view count (live count if YouTube API key is configured) - Date and view count (live count if YouTube API key is configured)
- "Follow on YouTube/Spotify" CTA and "View on YouTube/Spotify" CTA - "Subscribe on YouTube" / "Follow on Spotify" CTA and "View on YouTube" / "Listen on 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. - **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. - **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.
@@ -23,7 +23,8 @@ Content cards on the homepage, videos page, and podcast page currently link dire
- `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. - `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. - `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. - `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. - `conversion-ctas`: New CTA instances inside the modal ("Subscribe on YouTube" / "Follow on Spotify", "View on YouTube" / "Listen on Spotify") must follow the existing `CtaLink` tracking conventions.
- `conversion-ctas`: New CTA instances inside the modal ("Subscribe on YouTube" / "Follow on Spotify", "View on YouTube" / "Listen on Spotify") must follow the existing `CtaLink` tracking conventions.
## Impact ## Impact
@@ -32,5 +33,5 @@ Content cards on the homepage, videos page, and podcast page currently link dire
- **Pages**: `index.astro`, `videos.astro`, `podcast.astro` — must mount the modal and wire card click handlers. - **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. - **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. - **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`. - **Minimal ingestion/data model enhancements.** Podcast content items may include `audioUrl` (RSS enclosure) to support a first-party audio fallback when a Spotify episode embed URL is not available. Optionally, a small override map (`content/podcast-spotify-map.json`) can supply Spotify episode URLs for embedding.
- **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. - **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,48 @@
## ADDED Requirements
### Requirement: Modal CTAs for YouTube and Spotify
The media modal MUST render two CTA actions:
- "Subscribe on YouTube" / "Follow on Spotify" — links to the channel/podcast profile page
- "View on YouTube" / "Listen on Spotify" — links to the specific content item URL
The CTA label and destination MUST be determined by the content source:
- For `youtube` source: "Subscribe 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, "Listen 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 "Subscribe 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 "Listen 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 "Subscribe on YouTube" CTA inside the media modal
- **THEN** the system emits a `cta_click` event with `target_id=modal.cta.subscribe.youtube`, `placement=media_modal`, `platform=youtube`, and `target_url` set to the YouTube channel URL
#### Scenario: Modal CTA emits cta_click event (secondary)
- **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 emits cta_click event (podcast)
- **WHEN** a 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`, `platform=spotify`, and `target_url` set to the podcast profile URL
#### Scenario: Modal CTA emits cta_click event (podcast secondary)
- **WHEN** a user clicks the "Listen on Spotify" CTA inside the media modal
- **THEN** the system emits a `cta_click` event with `target_id=modal.cta.listen.spotify`, `placement=media_modal`, `platform=spotify`, and `target_url` set to the episode 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

@@ -40,16 +40,30 @@ The `media_preview_close` event MUST include:
### Requirement: Modal CTA namespace ### Requirement: Modal CTA namespace
The tracking taxonomy MUST define a `media_modal` placement value for CTA interactions within the media modal. 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`. Modal CTAs MUST use `target_id` values in the namespace `modal.cta.{action}.{platform}`.
The `action` value MUST be one of:
- `subscribe` (YouTube channel)
- `view` (YouTube video)
- `follow` (Spotify podcast profile)
- `listen` (Spotify episode)
#### Scenario: User clicks "View on YouTube" in modal #### Scenario: User clicks "View on YouTube" in modal
- **WHEN** the user clicks the "View on YouTube" CTA inside the media 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` - **THEN** the system emits a `cta_click` event with `target_id=modal.cta.view.youtube`, `placement=media_modal`, and `platform=youtube`
#### Scenario: User clicks "Subscribe on YouTube" in modal
- **WHEN** the user clicks the "Subscribe on YouTube" CTA inside the media modal
- **THEN** the system emits a `cta_click` event with `target_id=modal.cta.subscribe.youtube`, `placement=media_modal`, and `platform=youtube`
#### Scenario: User clicks "Follow on Spotify" in modal #### Scenario: User clicks "Follow on Spotify" in modal
- **WHEN** the user clicks the "Follow on Spotify" CTA inside the media 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` - **THEN** the system emits a `cta_click` event with `target_id=modal.cta.follow.spotify`, `placement=media_modal`, and `platform=spotify`
#### Scenario: User clicks "Listen on Spotify" in modal
- **WHEN** the user clicks the "Listen on Spotify" CTA inside the media modal
- **THEN** the system emits a `cta_click` event with `target_id=modal.cta.listen.spotify`, `placement=media_modal`, and `platform=spotify`
## MODIFIED Requirements ## MODIFIED Requirements
### Requirement: Minimum required properties ### Requirement: Minimum required properties

View File

@@ -5,22 +5,24 @@ The site MUST provide a modal dialog that displays embedded media (YouTube video
The modal MUST render the following elements in order: 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 - 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) - An embedded media player (YouTube iframe for videos; Spotify embed for podcast episodes when the URL is a Spotify URL; otherwise an in-modal audio player when an `audioUrl` is available)
- The full description/summary text (not truncated) - The full description/summary text (not truncated)
- The publish date and view count (when available) - The publish date and view count (when available)
- A "Follow on YouTube/Spotify" CTA and a "View on YouTube/Spotify" CTA - A "Subscribe on YouTube" / "Follow on Spotify" CTA and a "View on YouTube" / "Listen on Spotify" CTA
#### Scenario: User clicks a YouTube video card #### Scenario: User clicks a YouTube video card
- **WHEN** a user clicks a video content card on any listing surface - **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" - **THEN** a modal dialog opens displaying a YouTube iframe embed, the video title, full description, date, view count (if available), and CTAs for "Subscribe on YouTube" and "View on YouTube"
#### Scenario: User clicks a podcast episode card with a Spotify URL #### 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 - **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" - **THEN** a modal dialog opens displaying a Spotify episode embed, the episode title, full description, date, and CTAs for "Follow on Spotify" and "Listen on Spotify"
#### Scenario: User clicks a podcast episode card with a non-Spotify URL #### 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 - **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 - **THEN** the modal dialog opens displaying the episode metadata (title, description, date) and either:
- an in-modal audio player when an `audioUrl` is available
- otherwise, a "Listen on Spotify" outbound link
#### Scenario: Modal renders with missing optional fields #### Scenario: Modal renders with missing optional fields
- **WHEN** a content item has no view count or no summary - **WHEN** a content item has no view count or no summary

View File

@@ -0,0 +1,52 @@
## 1. Card component changes
- [x] 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.
- [x] 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.
- [x] 1.3 Update `ContentCard.astro` to use `mode="modal"` for `youtube` and `podcast` sources, and keep `mode="link"` (current behavior) for other sources.
- [x] 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
- [x] 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.
- [x] 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.
- [x] 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()`.
- [x] 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, embed Spotify when a Spotify episode ID is available (`https://open.spotify.com/embed/episode/{id}`). For non-Spotify podcast URLs, render an in-modal audio player when an `audioUrl` is available, otherwise show a "Listen on Spotify" link.
- [x] 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.
- [x] 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
- [x] 3.1 Set `aria-modal="true"` and `aria-labelledby` (referencing the title element) on the `<dialog>`.
- [x] 3.2 Add `aria-label="Close"` to the close button.
- [x] 3.3 Implement focus return: store a reference to the clicked card before opening, restore focus to it on close.
- [x] 3.4 Verify focus trapping works with the native `<dialog>` (Tab cycles through modal focusables only).
- [x] 3.5 Verify `prefers-reduced-motion` suppresses modal open/close animations (covered by the existing global CSS rule).
## 4. Modal CTAs
- [x] 4.1 Render "Subscribe on YouTube" / "Follow on Spotify" CTA inside the modal, linking to the channel/podcast profile URL (from `LINKS.youtubeChannel` / `LINKS.podcast`). Apply UTM parameters.
- [x] 4.2 Render "View on YouTube" / "Listen on Spotify" CTA inside the modal, linking to the specific content item URL. Apply UTM parameters.
- [x] 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. Actions: `subscribe`/`view` for YouTube; `follow`/`listen` for Spotify.
## 5. Umami analytics updates
- [x] 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.
- [x] 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).
- [x] 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.
- [x] 5.4 Add new test cases for modal CTA Umami attributes (`cta_click`, `target_id`, `placement=media_modal`).
## 6. Page integration
- [x] 6.1 Add the `MediaModal` component to `index.astro` (homepage).
- [x] 6.2 Add the `MediaModal` component to `videos.astro`.
- [x] 6.3 Add the `MediaModal` component to `podcast.astro`.
## 7. Verification
- [x] 7.1 Run `npm run build` in `site/` and verify no build errors.
- [x] 7.2 Run `npm test` in `site/` and verify all tests pass (including updated Umami attribute tests).
Note: `site/tests/blog-nav.test.ts` is failing in the repo for reasons unrelated to this change.
- [x] 7.3 Manual smoke test: click a video card → modal opens with YouTube embed → close → playback stops. Repeat with Escape and backdrop click.
- [x] 7.4 Manual smoke test: click a podcast card → modal opens with Spotify embed (or fallback link) → close → playback stops.
- [x] 7.5 Verify modal accessibility: keyboard-only navigation, focus trap, focus return, screen reader announces dialog.
- [x] 7.6 Verify responsive behavior: modal layout on desktop and mobile viewports.

View File

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

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

@@ -17,6 +17,8 @@ When Umami is disabled or not configured, the site MUST still function and MUST
When Umami is enabled, the site MUST support custom event emission for: When Umami is enabled, the site MUST support custom event emission for:
- `cta_click` - `cta_click`
- `outbound_click` - `outbound_click`
- `media_preview`
- `media_preview_close`
- a general click interaction event for all instrumented clickable items (per the site tracking taxonomy) - 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. Each emitted event MUST include enough properties to segment reports by platform and placement when applicable.
@@ -27,6 +29,8 @@ The site MUST instrument tracked clickables using Umami's supported Track Events
- `data-umami-event="<event-name>"` - `data-umami-event="<event-name>"`
- optional event data using `data-umami-event-*` - 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: 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-title`
- `data-umami-event-type` - `data-umami-event-type`
@@ -47,6 +51,18 @@ For content-related links (clickables representing a specific piece of content),
- **WHEN** a user clicks an element with no tracking metadata - **WHEN** a user clicks an element with no tracking metadata
- **THEN** the system does not throw and navigation/interaction proceeds normally - **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`
### Requirement: Environment configuration ### Requirement: Environment configuration
The site MUST support configuration of Umami parameters (at minimum: website ID and script URL) without requiring code changes. The site MUST support configuration of Umami parameters (at minimum: website ID and script URL) without requiring code changes.

View File

@@ -18,6 +18,12 @@ The standard card layout MUST be:
If a field is not available (for example, views for some sources), the card MUST still render cleanly with that field omitted. 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 #### Scenario: Card renders with all fields
- **WHEN** a content item has an image, title, summary, publish date, views, and source - **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 - **THEN** the card renders those fields in the standard card layout order
@@ -37,3 +43,15 @@ If a field is not available (for example, views for some sources), the card MUST
#### Scenario: Card image load failure shows static placeholder #### Scenario: Card image load failure shows static placeholder
- **WHEN** a content item has an image URL but the image fails to load - **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 - **THEN** the card media area displays a static placeholder (no broken image icon) and the card remains visually intact
#### 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

@@ -38,3 +38,50 @@ In addition, CTA clicks MUST conform to the site click tracking taxonomy and MUS
#### Scenario: Two CTAs to the same destination #### Scenario: Two CTAs to the same destination
- **WHEN** two CTAs link to the same destination but appear in different placements - **WHEN** two CTAs link to the same destination but appear in different placements
- **THEN** their emitted events contain different `target_id` values - **THEN** their emitted events contain different `target_id` values
### Requirement: Modal CTAs for YouTube and Spotify
The media modal MUST render two CTA actions:
- "Subscribe on YouTube" / "Follow on Spotify" — links to the channel/podcast profile page
- "View on YouTube" / "Listen on Spotify" — links to the specific content item URL
The CTA label and destination MUST be determined by the content source:
- For `youtube` source: "Subscribe 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, "Listen 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 "Subscribe 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 "Listen 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 "Subscribe on YouTube" CTA inside the media modal
- **THEN** the system emits a `cta_click` event with `target_id=modal.cta.subscribe.youtube`, `placement=media_modal`, `platform=youtube`, and `target_url` set to the YouTube channel URL
#### Scenario: Modal CTA emits cta_click event (secondary)
- **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 emits cta_click event (podcast)
- **WHEN** a 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`, `platform=spotify`, and `target_url` set to the podcast profile URL
#### Scenario: Modal CTA emits cta_click event (podcast secondary)
- **WHEN** a user clicks the "Listen on Spotify" CTA inside the media modal
- **THEN** the system emits a `cta_click` event with `target_id=modal.cta.listen.spotify`, `placement=media_modal`, `platform=spotify`, and `target_url` set to the episode 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

@@ -28,6 +28,70 @@ The taxonomy MUST define namespaces for repeated UI surfaces. For the blog surfa
- **WHEN** two links point to the same destination but appear in different placements - **WHEN** two links point to the same destination but appear in different placements
- **THEN** their `target_id` values are different so their clicks can be measured independently - **THEN** their `target_id` values are different so their clicks can be measured independently
### 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}`.
The `action` value MUST be one of:
- `subscribe` (YouTube channel)
- `view` (YouTube video)
- `follow` (Spotify podcast profile)
- `listen` (Spotify episode)
#### 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 "Subscribe on YouTube" in modal
- **WHEN** the user clicks the "Subscribe on YouTube" CTA inside the media modal
- **THEN** the system emits a `cta_click` event with `target_id=modal.cta.subscribe.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`
#### Scenario: User clicks "Listen on Spotify" in modal
- **WHEN** the user clicks the "Listen on Spotify" CTA inside the media modal
- **THEN** the system emits a `cta_click` event with `target_id=modal.cta.listen.spotify`, `placement=media_modal`, and `platform=spotify`
### Requirement: Minimum required properties ### Requirement: Minimum required properties
Every tracked click event MUST include, at minimum: Every tracked click event MUST include, at minimum:
- `target_id` - `target_id`
@@ -46,6 +110,11 @@ The `type` value MUST be one of:
- `blog_post` - `blog_post`
- `blog_page` - `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 #### Scenario: Tracking a content card click
- **WHEN** a user clicks a content card link - **WHEN** a user clicks a content card link
- **THEN** the emitted event includes `target_id`, `placement`, and `target_url` - **THEN** the emitted event includes `target_id`, `placement`, and `target_url`
@@ -54,6 +123,10 @@ The `type` value MUST be one of:
- **WHEN** a user clicks a content-related link that represents a specific content item - **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` - **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)
### Requirement: No PII in event properties ### Requirement: No PII in event properties
The taxonomy MUST prohibit including personally identifiable information (PII) in event names or event properties. The taxonomy MUST prohibit including personally identifiable information (PII) in event names or event properties.

View File

@@ -0,0 +1,92 @@
## Purpose
Define the media modal dialog behavior for in-page video and podcast previews.
## 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; otherwise an in-modal audio player when an `audioUrl` is available)
- The full description/summary text (not truncated)
- The publish date and view count (when available)
- A "Subscribe on YouTube" / "Follow on Spotify" CTA and a "View on YouTube" / "Listen on 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 "Subscribe 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 "Listen 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 either:
- an in-modal audio player when an `audioUrl` is available
- otherwise, a "Listen on Spotify" outbound link
#### 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

@@ -5,13 +5,13 @@
*/ */
// Bump this value on deploy to invalidate caches. // Bump this value on deploy to invalidate caches.
const CACHE_VERSION = "v2"; const CACHE_VERSION = "v3";
const CACHE_SHELL = `shell-${CACHE_VERSION}`; const CACHE_SHELL = `shell-${CACHE_VERSION}`;
const CACHE_PAGES = `pages-${CACHE_VERSION}`; const CACHE_PAGES = `pages-${CACHE_VERSION}`;
const CACHE_MEDIA = `media-${CACHE_VERSION}`; const CACHE_MEDIA = `media-${CACHE_VERSION}`;
const SHELL_ASSETS = ["/", "/styles/global.css", "/favicon.png", "/favicon.png", "/robots.txt"]; const SHELL_ASSETS = ["/", "/styles/global.css", "/favicon.png", "/robots.txt"];
// Keep media cache bounded so we don't grow indefinitely. // Keep media cache bounded so we don't grow indefinitely.
const MAX_MEDIA_ENTRIES = 80; const MAX_MEDIA_ENTRIES = 80;

View File

@@ -57,15 +57,16 @@ const canonicalUrl = `${siteUrl}${canonicalPath.startsWith("/") ? canonicalPath
} }
{ {
// Register SW only in production builds (Astro sets import.meta.env.PROD at build time) // Register SW only in production builds (Astro sets import.meta.env.PROD at build time).
// and only when explicitly enabled by env. // Allow opting out via PUBLIC_ENABLE_SW="false".
import.meta.env.PROD && import.meta.env.PUBLIC_ENABLE_SW === "true" ? ( import.meta.env.PROD && import.meta.env.PUBLIC_ENABLE_SW !== "false" ? (
<script is:inline> <script is:inline>
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator) {
// SW requires HTTPS (or localhost). In prod we expect HTTPS. // SW requires HTTPS (or localhost). In prod we expect HTTPS.
window.addEventListener("load", () => { window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch(() => { navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch((err) => {
// noop: SW is progressive enhancement // Progressive enhancement; keep failures non-fatal.
console.warn("Service worker registration failed", err);
}); });
}); });
} }