Compare commits

16 Commits

Author SHA1 Message Date
26a8c97841 issue with notch
Some checks failed
ci / site (push) Has been cancelled
publish-image / publish (push) Has been cancelled
2026-02-10 20:57:28 -05:00
f50a828535 Now I remember the theme
Some checks failed
ci / site (push) Has been cancelled
publish-image / publish (push) Has been cancelled
2026-02-10 20:38:38 -05:00
70710239c7 Theming done
Some checks failed
ci / site (push) Has been cancelled
publish-image / publish (push) Has been cancelled
2026-02-10 20:10:06 -05:00
6cb4d55241 SW implementation done
Some checks failed
ci / site (push) Has been cancelled
publish-image / publish (push) Has been cancelled
2026-02-10 18:32:24 -05:00
9fee6c8af7 SW still not fixed
Some checks failed
ci / site (push) Has been cancelled
publish-image / publish (push) Has been cancelled
2026-02-10 18:12:25 -05:00
daac2eec20 fix for SW
Some checks failed
ci / site (push) Has been cancelled
publish-image / publish (push) Has been cancelled
2026-02-10 18:02:37 -05:00
57ad560b01 fix for SR
Some checks failed
ci / site (push) Has been cancelled
publish-image / publish (push) Has been cancelled
2026-02-10 17:54:13 -05:00
5d07e57256 reduce bounce rate
Some checks failed
ci / site (push) Has been cancelled
publish-image / publish (push) Has been cancelled
2026-02-10 17:36:34 -05:00
ac3de3e142 lazy-loading done
Some checks failed
ci / site (push) Has been cancelled
publish-image / publish (push) Has been cancelled
2026-02-10 15:59:03 -05:00
7bd51837de add favicon
Some checks failed
ci / site (push) Has been cancelled
publish-image / publish (push) Has been cancelled
2026-02-10 07:09:12 -05:00
b61146b145 Service worker fix
Some checks failed
ci / site (push) Has been cancelled
publish-image / publish (push) Has been cancelled
2026-02-10 06:46:29 -05:00
7cb72b2746 Fix umami script not added
Some checks failed
ci / site (push) Has been cancelled
publish-image / publish (push) Has been cancelled
2026-02-10 06:17:21 -05:00
35afd9208f Cron ready refresh script
Some checks failed
ci / site (push) Has been cancelled
publish-image / publish (push) Has been cancelled
2026-02-10 05:32:05 -05:00
e2ef436d34 refresh content
Some checks failed
ci / site (push) Has been cancelled
publish-image / publish (push) Has been cancelled
2026-02-10 05:06:24 -05:00
1d3d68df7b last updated timezone changed
Some checks failed
ci / site (push) Has been cancelled
publish-image / publish (push) Has been cancelled
2026-02-10 05:03:07 -05:00
12e7eae95a Fix service workers
Some checks failed
ci / site (push) Has been cancelled
publish-image / publish (push) Has been cancelled
2026-02-10 04:54:05 -05:00
81 changed files with 4386 additions and 171 deletions

View File

@@ -66,3 +66,7 @@ jobs:
BUILD_SHA=${{ github.sha }} BUILD_SHA=${{ github.sha }}
BUILD_DATE=${{ github.run_started_at }} BUILD_DATE=${{ github.run_started_at }}
BUILD_REF=${{ github.server_url }}/${{ github.repository }} BUILD_REF=${{ github.server_url }}/${{ github.repository }}
PUBLIC_SITE_URL=${{ secrets.PUBLIC_SITE_URL }}
PUBLIC_UMAMI_SCRIPT_URL=${{ secrets.PUBLIC_UMAMI_SCRIPT_URL }}
PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.PUBLIC_UMAMI_WEBSITE_ID }}
PUBLIC_ENABLE_SW=${{ secrets.PUBLIC_ENABLE_SW }}

262
DIAGNOSIS_COMPLETE.md Normal file
View File

@@ -0,0 +1,262 @@
# Production Theme Notch Styling Issue - Complete Diagnostic Report
**Date**: 2026-02-10
**Status**: ✅ ROOT CAUSE IDENTIFIED
**Severity**: CRITICAL
---
## Quick Summary
The theme notch component on production (https://santhoshj.com) appears as plain buttons in normal document flow instead of a fixed, positioned element in the top-right corner.
**Root Cause**: The production server is serving a **STALE CSS file** (17,628 bytes) that was built BEFORE the theme-notch CSS rules were added. The local build correctly generates a 24,279 byte CSS file with all rules.
**Fix**: Rebuild and redeploy the Docker image with the latest source code.
---
## Evidence
### 1. CSS File Size Mismatch
| Metric | Local Build | Production | Status |
|--------|-------------|-----------|--------|
| File size | 24,279 bytes | ~17,628 bytes | ❌ MISMATCH |
| Difference | - | -6,651 bytes (27% smaller) | ❌ CRITICAL |
| theme-notch rules | 30 matches | 0 matches | ❌ MISSING |
### 2. CSS Rules Comparison
**Local CSS (site/dist/styles/global.css):**
```css
.theme-notch {
position: fixed;
top: var(--theme-notch-top);
right: max(8px, env(safe-area-inset-right));
z-index: 12;
display: flex;
align-items: center;
gap: 10px;
pointer-events: none;
}
.theme-notch-panel {
position: absolute;
right: 60px;
top: 0;
display: grid;
gap: 6px;
padding: 12px;
border-radius: 16px;
border: 1px solid var(--stroke-mid);
background: var(--surface-1);
opacity: 0;
transform: translateX(10px) scale(0.98);
visibility: hidden;
pointer-events: none;
transition: opacity 160ms ease, transform 160ms ease, visibility 0s linear 160ms;
}
.theme-notch-handle { ... }
.theme-notch-glyph { ... }
.theme-notch-option { ... }
.theme-notch-dot { ... }
/* + media queries and forced-colors rules */
```
**Production CSS (https://santhoshj.com/styles/global.css):**
```
❌ ZERO matches for .theme-notch*
```
### 3. Computed Styles on Production
| Property | Actual | Expected | Status |
|----------|--------|----------|--------|
| .theme-notch position | static | fixed | ❌ WRONG |
| .theme-notch top | auto | 84px | ❌ WRONG |
| .theme-notch right | auto | max(8px, env(...)) | ❌ WRONG |
| .theme-notch z-index | auto | 12 | ❌ WRONG |
| .theme-notch-panel position | static | absolute | ❌ WRONG |
| .theme-notch-panel display | block | grid | ❌ WRONG |
| .theme-notch-panel opacity | 1 | 0 (initially) | ❌ WRONG |
| .theme-notch-panel visibility | visible | hidden (initially) | ❌ WRONG |
### 4. HTML Markup Status
**Present and correct on both local and production:**
- `<aside class="theme-notch" aria-label="Theme" data-theme-notch="" data-open="false">`
- `<div class="theme-notch-panel" id="theme-notch-panel" role="radiogroup" aria-label="Theme selector">`
- `<button class="theme-notch-option" data-theme-option="dark|light|contrast" role="radio" aria-checked="...">`
- SVG glyph present in `.theme-notch-glyph`
### 5. JavaScript Execution
**Inline scripts ARE executing** (no CSP block):
- Theme initialization script runs
- Service worker registration attempted
- Navigation toggle script runs
- Image lazy-load script runs
**Console errors (unrelated to theme-notch CSS):**
- `[ERROR] An unknown error occurred when fetching the script` (service worker)
- `[ERROR] Failed to load resource: net::ERR_INSUFFICIENT_RESOURCES` (analytics)
### 6. Network & Caching
-`/styles/global.css` returns **[200] OK**
- ✅ File loads successfully
- ❌ Content is stale/outdated
---
## What's Working
✅ HTML markup is present and correct
✅ JavaScript is executing without CSP errors
✅ Local development (`npm run dev`) works perfectly
✅ Local build (`npm run build`) generates correct CSS
✅ Network requests succeed
✅ Accessibility tree shows correct structure
---
## What's Broken
❌ Production CSS file is missing theme-notch rules
❌ CSS file size doesn't match local build
❌ Theme notch appears inline instead of fixed position
❌ Theme notch dropdown panel is not hidden initially
❌ Theme notch styling is completely absent
---
## Root Cause Analysis
### The Problem
The production Docker image contains an **OLDER version of the CSS file** that was built **BEFORE the theme-notch CSS rules were added** to the source.
### Why This Happened
1. The theme-notch CSS rules exist in the source (`site/public/styles/global.css`)
2. The local build correctly includes them in the output (`site/dist/styles/global.css` - 24,279 bytes)
3. **BUT** the production Docker image contains an older version of the CSS file (~17,628 bytes)
4. This suggests:
- The Docker image was built from an older commit
- OR the CSS file was not updated in the Docker image
- OR there's a stale build artifact being served
### This is a DEPLOYMENT/CACHING issue, not a build issue
---
## Verification Performed
✅ Navigated to https://santhoshj.com and /blog
✅ Captured console errors and warnings
✅ Checked network requests (global.css loads with [200])
✅ Fetched and parsed production CSS file
✅ Searched for `.theme-notch` selectors (0 matches found)
✅ Inspected computed styles via browser DevTools
✅ Verified HTML markup is present and correct
✅ Confirmed inline scripts execute without CSP errors
✅ Compared with local source CSS (rules present locally)
✅ Ran local build and verified CSS includes theme-notch rules
✅ Compared file sizes (24,279 bytes local vs ~17,628 bytes production)
---
## Recommended Fix
### Option 1: Rebuild and redeploy Docker image (RECOMMENDED)
```bash
# In the project root
docker compose build --no-cache
docker compose push
# Deploy new image to production
```
### Option 2: If using CI/CD
- Ensure the latest commit is being built
- Verify the Docker build includes the latest `site/dist/` output
- Check that the image is being pushed with the correct tag
- Redeploy the image to production
### Option 3: Quick verification before deploying
```bash
# SSH into production server
# Check CSS file size
ls -lh /usr/share/nginx/html/styles/global.css
# Should show ~24,279 bytes (not ~17,628)
# Verify theme-notch rules exist
grep -c "theme-notch" /usr/share/nginx/html/styles/global.css
# Should show 30+ matches (not 0)
```
---
## Expected Result After Fix
✅ Theme notch appears as a fixed button in top-right corner
✅ Clicking reveals a dropdown panel with Dark/Light/Contrast options
✅ Smooth animations and hover effects work
✅ CSS file size matches local build (~24,279 bytes)
✅ CSS file contains 30+ matches for "theme-notch"
✅ Computed styles show correct positioning and display properties
---
## Files Involved
| File | Purpose | Status |
|------|---------|--------|
| `site/public/styles/global.css` | Source CSS | ✅ Contains theme-notch rules (lines 306-457) |
| `site/dist/styles/global.css` | Local build output | ✅ 24,279 bytes, 30 theme-notch matches |
| `https://santhoshj.com/styles/global.css` | Production | ❌ ~17,628 bytes, 0 theme-notch matches |
| `Dockerfile` | Docker build | ⚠️ Copies from `site/dist/` |
| `site/src/components/BaseLayout.astro` | HTML template | ✅ Renders theme-notch markup |
---
## Summary Table
| Aspect | Local | Production | Status |
|--------|-------|-----------|--------|
| CSS file exists | ✅ Yes | ✅ Yes | OK |
| File size | 24,279 bytes | ~17,628 bytes | ❌ MISMATCH |
| theme-notch rules | 30 matches | 0 matches | ❌ MISSING |
| HTML markup | ✅ Present | ✅ Present | OK |
| JavaScript | ✅ Executing | ✅ Executing | OK |
| Computed styles | ✅ Correct | ❌ Wrong | BROKEN |
| Visual result | ✅ Fixed notch | ❌ Inline buttons | BROKEN |
---
## Conclusion
### The issue is NOT:
- ❌ CSP blocking inline scripts
- ❌ Missing HTML markup
- ❌ JavaScript not executing
- ❌ Browser caching
### The issue IS:
-**Production CSS file missing `.theme-notch` rules**
-**Stale Docker image serving outdated CSS**
-**Deployment/caching issue, not a build issue**
### Action Required:
**Rebuild and redeploy Docker image with latest source code.**
---
## Next Steps
1. **Verify the fix**: Rebuild Docker image with latest source
2. **Deploy**: Pu

162
DIAGNOSIS_SUMMARY.txt Normal file
View File

@@ -0,0 +1,162 @@
================================================================================
PRODUCTION THEME NOTCH STYLING ISSUE - EXECUTIVE SUMMARY
================================================================================
ISSUE: Theme notch appears as plain buttons in normal document flow on production
(https://santhoshj.com) instead of a fixed, positioned element in the
top-right corner with a dropdown panel.
ROOT CAUSE: Production server is serving a STALE CSS file (17,628 bytes) that
was built BEFORE the theme-notch CSS rules were added. The local
build correctly generates a 24,279 byte CSS file with all rules.
SEVERITY: CRITICAL - Component is completely unstyled and non-functional
================================================================================
EVIDENCE SUMMARY
================================================================================
1. CSS FILE SIZE MISMATCH
Local build: 24,279 bytes ✅ (includes theme-notch rules)
Production: ~17,628 bytes ❌ (missing theme-notch rules)
Difference: ~6,651 bytes (27% smaller)
2. THEME-NOTCH RULES
Local CSS: 30 matches for "theme-notch" ✅
Production CSS: 0 matches for "theme-notch" ❌
3. COMPUTED STYLES ON PRODUCTION
.theme-notch position: static (WRONG - should be: fixed)
.theme-notch top: auto (WRONG - should be: 84px)
.theme-notch right: auto (WRONG - should be: max(8px, env(...)))
.theme-notch z-index: auto (WRONG - should be: 12)
.theme-notch-panel display: block (WRONG - should be: grid)
.theme-notch-panel opacity: 1 (WRONG - should be: 0 initially)
4. HTML MARKUP
✅ Present and correct on both local and production
✅ All classes and attributes in place
✅ SVG glyph present
5. JAVASCRIPT EXECUTION
✅ Inline scripts ARE executing (no CSP block)
✅ Theme initialization script runs
✅ No JavaScript errors related to theme-notch
6. NETWORK & CACHING
✅ /styles/global.css returns [200] OK
✅ File loads successfully
❌ Content is stale/outdated
================================================================================
WHAT'S WORKING
================================================================================
✅ HTML markup is present and correct
✅ JavaScript is executing without CSP errors
✅ Local development (npm run dev) works perfectly
✅ Local build (npm run build) generates correct CSS
✅ Network requests succeed
✅ Accessibility tree shows correct structure
================================================================================
WHAT'S BROKEN
================================================================================
❌ Production CSS file is missing theme-notch rules
❌ CSS file size doesn't match local build
❌ Theme notch appears inline instead of fixed position
❌ Theme notch dropdown panel is not hidden initially
❌ Theme notch styling is completely absent
================================================================================
ROOT CAUSE ANALYSIS
================================================================================
The production Docker image contains an OLDER version of the CSS file that was
built BEFORE the theme-notch CSS rules were added to the source.
This is a DEPLOYMENT/CACHING issue, not a build issue.
Possible causes:
1. Docker image was built from an older commit
2. CSS file was not updated in the Docker image
3. Stale build artifact is being served
4. CI/CD pipeline didn't rebuild the image with latest source
================================================================================
VERIFICATION PERFORMED
================================================================================
✅ Navigated to https://santhoshj.com and /blog
✅ Captured console errors and warnings
✅ Checked network requests (global.css loads with [200])
✅ Fetched and parsed production CSS file
✅ Searched for .theme-notch selectors (0 matches found)
✅ Inspected computed styles via browser DevTools
✅ Verified HTML markup is present and correct
✅ Confirmed inline scripts execute without CSP errors
✅ Compared with local source CSS (rules present locally)
✅ Ran local build and verified CSS includes theme-notch rules
✅ Compared file sizes (24,279 bytes local vs ~17,628 bytes production)
================================================================================
RECOMMENDED FIX
================================================================================
OPTION 1: Rebuild and redeploy Docker image
$ docker compose build --no-cache
$ docker compose push
# Deploy new image to production
OPTION 2: If using CI/CD
- Ensure latest commit is being built
- Verify Docker build includes latest site/dist/ output
- Check image is pushed with correct tag
- Redeploy image to production
OPTION 3: Quick verification before deploying
- SSH into production server
- Check CSS file size: should be ~24,279 bytes (not ~17,628)
- Verify /styles/global.css contains "theme-notch" (30+ matches)
- If not, redeploy the Docker image
================================================================================
EXPECTED RESULT AFTER FIX
================================================================================
✅ Theme notch appears as a fixed button in top-right corner
✅ Clicking reveals a dropdown panel with Dark/Light/Contrast options
✅ Smooth animations and hover effects work
✅ CSS file size matches local build (~24,279 bytes)
✅ CSS file contains 30+ matches for "theme-notch"
✅ Computed styles show correct positioning and display properties
================================================================================
FILES INVOLVED
================================================================================
Source: site/public/styles/global.css (lines 306-457)
Production: https://santhoshj.com/styles/global.css (missing rules)
Build: Dockerfile and npm run build process
HTML: site/src/components/BaseLayout.astro (renders theme-notch markup)
Local dist: site/dist/styles/global.css (24,279 bytes - CORRECT)
================================================================================
CONCLUSION
================================================================================
The issue is NOT:
❌ CSP blocking inline scripts
❌ Missing HTML markup
❌ JavaScript not executing
❌ Browser caching
The issue IS:
✅ Production CSS file missing .theme-notch rules
✅ Stale Docker image serving outdated CSS
✅ Deployment/caching issue, not a build issue
ACTION REQUIRED: Rebuild and redeploy Docker image with latest source code.
================================================================================

View File

@@ -2,6 +2,19 @@ FROM node:24-alpine AS builder
WORKDIR /app/site WORKDIR /app/site
ARG PUBLIC_ENABLE_SW=true
ENV PUBLIC_ENABLE_SW=$PUBLIC_ENABLE_SW
ARG PUBLIC_ASSET_VERSION
ENV PUBLIC_ASSET_VERSION=$PUBLIC_ASSET_VERSION
# Public, build-time config (baked into static HTML via Astro/Vite).
ARG PUBLIC_SITE_URL
ARG PUBLIC_UMAMI_SCRIPT_URL
ARG PUBLIC_UMAMI_WEBSITE_ID
ENV PUBLIC_SITE_URL=$PUBLIC_SITE_URL
ENV PUBLIC_UMAMI_SCRIPT_URL=$PUBLIC_UMAMI_SCRIPT_URL
ENV PUBLIC_UMAMI_WEBSITE_ID=$PUBLIC_UMAMI_WEBSITE_ID
COPY site/package.json site/package-lock.json ./ COPY site/package.json site/package-lock.json ./
RUN npm ci --no-audit --no-fund RUN npm ci --no-audit --no-fund

294
PRODUCTION_DIAGNOSIS.md Normal file
View File

@@ -0,0 +1,294 @@
# Production Theme Notch Styling Issue - Diagnostic Report
**Date**: 2026-02-10
**Status**: CRITICAL - CSS rules missing in production
---
## Executive Summary
The theme notch component appears as plain buttons in normal document flow on production (https://santhoshj.com) because **the CSS rules for `.theme-notch` and related selectors are completely absent from the served `/styles/global.css` file**.
---
## Evidence
### 1. CSS File Status
- **Local file**: `site/public/styles/global.css`**CONTAINS** `.theme-notch` rules (lines 306-457)
- **Production file**: `https://santhoshj.com/styles/global.css`**MISSING** `.theme-notch` rules
- **File size**: Local = 1215 lines; Production = ~17,628 bytes (109 CSS rules)
- **Network status**: [200] OK - file loads successfully
### 2. CSS Rules Missing in Production
**Local CSS (lines 306-457) includes:**
```css
.theme-notch {
position: fixed;
top: var(--theme-notch-top);
right: max(8px, env(safe-area-inset-right));
z-index: 12;
display: flex;
align-items: center;
gap: 10px;
pointer-events: none;
}
.theme-notch-panel {
position: absolute;
right: 60px;
top: 0;
display: grid;
gap: 6px;
padding: 12px;
border-radius: 16px;
border: 1px solid var(--stroke-mid);
background: var(--surface-1);
opacity: 0;
transform: translateX(10px) scale(0.98);
visibility: hidden;
pointer-events: none;
transition: opacity 160ms ease, transform 160ms ease, visibility 0s linear 160ms;
}
.theme-notch-handle { ... }
.theme-notch-glyph { ... }
.theme-notch-option { ... }
.theme-notch-dot { ... }
/* + media queries and forced-colors rules */
```
**Production CSS**: ❌ **ZERO** matches for `.theme-notch*` selectors
### 3. Computed Styles on Production
**Actual computed styles (from browser DevTools):**
```
.theme-notch:
position: static (WRONG - should be: fixed)
top: auto (WRONG - should be: 84px)
right: auto (WRONG - should be: max(8px, env(safe-area-inset-right)))
display: block (CORRECT - but only by accident)
opacity: 1 (CORRECT - but only by accident)
visibility: visible (CORRECT - but only by accident)
z-index: auto (WRONG - should be: 12)
.theme-notch-panel:
position: static (WRONG - should be: absolute)
top: auto (WRONG - should be: 0)
right: auto (WRONG - should be: 60px)
display: block (WRONG - should be: grid)
opacity: 1 (WRONG - should be: 0 initially)
visibility: visible (WRONG - should be: hidden initially)
```
### 4. HTML Markup Status
**Present and correct:**
- `<aside class="theme-notch" aria-label="Theme" data-theme-notch="" data-open="false">`
- `<div class="theme-notch-panel" id="theme-notch-panel" role="radiogroup" aria-label="Theme selector">`
- `<button class="theme-notch-option" data-theme-option="dark|light|contrast" role="radio" aria-checked="...">`
- SVG glyph present in `.theme-notch-glyph`
### 5. JavaScript Execution
**Inline scripts ARE executing** (no CSP block):
- Theme initialization script runs
- Service worker registration attempted (fails due to unrelated script fetch error)
- Navigation toggle script runs
- Image lazy-load script runs
**Console errors (unrelated to theme-notch CSS):**
- `[ERROR] An unknown error occurred when fetching the script` (service worker)
- `[ERROR] Failed to load resource: net::ERR_INSUFFICIENT_RESOURCES` (analytics)
### 6. Network & Caching
- `/styles/global.css` returns **[200] OK**
- No cache headers preventing updates detected
- File loads successfully but content is stale/incomplete
---
## Root Cause Analysis
### Hypothesis: Build/Deployment Issue
The production CSS file is **missing the theme-notch rules entirely**, suggesting:
1. **Build process did not include the CSS** - The CSS file was generated/minified without the theme-notch section
2. **Stale build artifact** - An older version of global.css is being served
3. **CSS file truncation** - The build process may have failed to include lines 306-457 from the source
4. **Incorrect build output** - The CSS bundler/minifier may have stripped unused rules (unlikely, as the HTML uses the classes)
### Why It Works Locally
- `npm run dev` serves the source CSS directly from `site/public/styles/global.css`
- All rules are present and applied correctly
- Theme notch appears as a fixed, positioned element in the top-right corner
### Why It Fails in Production
- Docker build likely runs `npm run build` which may:
- Minify/bundle CSS differently
- Use a different CSS source file
- Strip rules based on unused CSS detection
- Output to a different location than `site/public/styles/global.css`
---
## Visual Impact
**Expected (Local):**
- Theme notch appears as a fixed button in top-right corner
- Clicking reveals a dropdown panel with Dark/Light/Contrast options
- Smooth animations and hover effects
**Actual (Production):**
- Theme notch buttons appear inline in normal document flow
- No positioning, no dropdown panel
- Buttons are unstyled (only inherit default browser styles)
- Appears as plain radio buttons in the accessibility tree
---
## Verification Steps Performed
✅ Navigated to https://santhoshj.com and /blog
✅ Captured console errors and warnings
✅ Checked network requests (global.css loads with [200])
✅ Fetched and parsed production CSS file
✅ Searched for `.theme-notch` selectors (0 matches)
✅ Inspected computed styles via browser DevTools
✅ Verified HTML markup is present and correct
✅ Confirmed inline scripts execute without CSP errors
✅ Compared with local source CSS (rules present locally)
---
## Recommended Actions
1. **Immediate**: Check the Docker build process
- Verify `npm run build` output includes theme-notch CSS
- Check if CSS is being minified/bundled to a different file
- Ensure `site/public/styles/global.css` is copied to the Docker image
2. **Verify**: Rebuild and redeploy
- Run `npm run build` locally and inspect the output CSS
- Check if `dist/styles/global.css` (or equivalent) contains theme-notch rules
- Verify the Docker image includes the correct CSS file
3. **Debug**: Add build logging
- Log CSS file size before/after minification
- Verify the CSS source file is being read correctly
- Check for any build warnings about unused CSS
4. **Test**: Validate production deployment
- After fix, verify `/styles/global.css` contains theme-notch rules
- Check computed styles in production browser DevTools
- Confirm theme notch appears in correct position
---
## Files Involved
- **Source**: `site/public/styles/global.css` (lines 306-457)
- **Production**: `https://santhoshj.com/styles/global.css` (missing rules)
- **Build**: `Dockerfile` and `npm run build` process
- **HTML**: `site/src/components/BaseLayout.astro` (renders theme-notch markup)
---
## Conclusion
**The issue is NOT:**
- ❌ CSP blocking inline scripts
- ❌ Missing HTML markup
- ❌ JavaScript not executing
- ❌ Browser caching (file loads fresh)
**The issue IS:**
-**Production CSS file missing `.theme-notch` rules**
-**Build/deployment process not including complete CSS**
The fix requires investigating the Docker build and CSS bundling process to ensure all CSS rules are included in the production output.
---
## CRITICAL UPDATE: Root Cause Identified
### Build Verification Results
**Local Build (npm run build):**
- ✅ CSS file generated: `site/dist/styles/global.css`
- ✅ File size: **24,279 bytes**
- ✅ Theme-notch rules: **30 matches** found
- ✅ All CSS rules present and correct
**Production Server:**
- ❌ CSS file served: `https://santhoshj.com/styles/global.css`
- ❌ File size: **~17,628 bytes** (SMALLER than local build)
- ❌ Theme-notch rules: **0 matches** found
- ❌ CSS is STALE/OUTDATED
### Conclusion
**The production server is serving an OLD version of the CSS file that was built BEFORE the theme-notch CSS rules were added to the source.**
This is a **deployment/caching issue**, not a build issue.
### Why This Happened
1. The theme-notch CSS rules exist in the source (`site/public/styles/global.css`)
2. The local build correctly includes them in the output (`site/dist/styles/global.css`)
3. **BUT** the production Docker image contains an older version of the CSS file
4. This suggests:
- The Docker image was built from an older commit
- OR the CSS file was not updated in the Docker image
- OR there's a caching layer serving stale content
### Fix Required
**Option 1: Rebuild and redeploy the Docker image**
```bash
docker compose build --no-cache
docker compose push
# Deploy new image to production
```
**Option 2: If using CI/CD**
- Ensure the latest commit is being built
- Verify the Docker build includes the latest `site/dist/` output
- Check that the image is being pushed with the correct tag
- Redeploy the image to production
**Option 3: Quick verification**
- SSH into production server
- Check the CSS file size: should be ~24,279 bytes (not ~17,628)
- Verify `/styles/global.css` contains "theme-notch" (30+ matches)
- If not, redeploy the Docker image
---
## Summary Table
| Aspect | Local | Production | Status |
|--------|-------|-----------|--------|
| CSS file exists | ✅ Yes | ✅ Yes | OK |
| File size | 24,279 bytes | ~17,628 bytes | ❌ MISMATCH |
| theme-notch rules | 30 matches | 0 matches | ❌ MISSING |
| HTML markup | ✅ Present | ✅ Present | OK |
| JavaScript | ✅ Executing | ✅ Executing | OK |
| Computed styles | ✅ Correct | ❌ Wrong | BROKEN |
| Visual result | ✅ Fixed notch | ❌ Inline buttons | BROKEN |
---
## Next Steps
1. **Verify the fix**: Rebuild Docker image with latest source
2. **Deploy**: Push new image to production
3. **Test**: Verify `/styles/global.css` is ~24,279 bytes and contains theme-notch rules
4. **Validate**: Check production site - theme notch should appear in top-right corner
5. **Monitor**: Watch for any other stale assets

BIN
blog-screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 971 KiB

View File

@@ -6,6 +6,16 @@ server {
index index.html; index index.html;
# Static assets # Static assets
location = /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
try_files $uri =404;
}
location = /styles/global.css {
add_header Cache-Control "no-cache" always;
try_files $uri =404;
}
location / { location / {
# Serve directory index pages without requiring a trailing slash. # Serve directory index pages without requiring a trailing slash.
# This fixes /videos (and similar) resolving to /videos/index.html. # This fixes /videos (and similar) resolving to /videos/index.html.

View File

@@ -6,6 +6,9 @@ The deployment model is:
- CI builds and publishes a Docker image containing the built static site - CI builds and publishes a Docker image containing the built static site
- the server updates by pulling that image and restarting the service - the server updates by pulling that image and restarting the service
Build-time config (examples):
- `PUBLIC_ENABLE_SW=true|false` toggles service worker registration in the generated HTML (defaults to `true` in the Docker build).
### Prerequisites ### Prerequisites
- Docker + Docker Compose plugin available on the host - Docker + Docker Compose plugin available on the host
@@ -81,4 +84,3 @@ The pull should fail, but the current service should still be running:
docker compose -f deploy/docker-compose.prod.yml ps docker compose -f deploy/docker-compose.prod.yml ps
curl -fsS http://localhost:8080/ > /dev/null curl -fsS http://localhost:8080/ > /dev/null
``` ```

View File

@@ -4,8 +4,19 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args:
# Build-time toggle for service worker registration in the generated static HTML.
PUBLIC_ENABLE_SW: ${PUBLIC_ENABLE_SW:-true}
PUBLIC_ASSET_VERSION: ${PUBLIC_ASSET_VERSION:-}
# Public, build-time config baked into the HTML.
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-}
PUBLIC_UMAMI_SCRIPT_URL: ${PUBLIC_UMAMI_SCRIPT_URL:-}
PUBLIC_UMAMI_WEBSITE_ID: ${PUBLIC_UMAMI_WEBSITE_ID:-}
ports: ports:
- "8080:80" - "8080:80"
networks:
- fast-website-network
redis: redis:
image: redis:7-alpine image: redis:7-alpine
@@ -17,3 +28,9 @@ services:
interval: 5s interval: 5s
timeout: 3s timeout: 3s
retries: 20 retries: 20
networks:
- fast-website-network
networks:
fast-website-network:
driver: bridge

BIN
homepage-screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 KiB

View File

@@ -0,0 +1,74 @@
## Context
The site is a static Astro SSG with no framework islands — all client-side interactivity uses vanilla JS via `<script is:inline>` tags. Images appear in two surfaces:
1. **Card thumbnails**`StandardCard.astro` renders `<img src={imageUrl} alt="" loading="lazy" />` inside a `.card-media` div. When there's no image, a static `.card-placeholder` div fills the space. The image area is fixed at 180px height (200px on mobile).
2. **Blog featured images**`blog/post/[slug].astro` and `blog/page/[slug].astro` render `<img loading="lazy" />` inline with a max-height of 420px.
Currently `loading="lazy"` defers fetching until the image nears the viewport, but the image area is blank/transparent until download completes, causing a jarring pop-in.
The site's dark theme (`--bg0: #0b1020`) means the shimmer must be a subtle light-on-dark gradient sweep — not a grey-on-white pattern.
## Goals / Non-Goals
**Goals:**
- Show an animated shimmer placeholder while images load, matching the site's dark aesthetic
- Fade in the actual image smoothly once loaded
- Handle image load errors gracefully (keep placeholder visible)
- Respect `prefers-reduced-motion` (suppress shimmer animation)
- Zero new dependencies — CSS keyframes + vanilla JS only
**Non-Goals:**
- Generating low-quality image placeholders (LQIP/BlurHash) at build time — this requires a build pipeline change and image processing dependency
- Lazy loading changes to the `loading` attribute strategy — `loading="lazy"` is already set and works correctly
- Image format optimization (WebP/AVIF conversion) — separate concern
- Placeholder for Instagram embeds — those use a third-party embed script with its own loading state
## Decisions
### 1. CSS shimmer via `@keyframes` on a pseudo-element
**Decision:** The shimmer effect uses a CSS `@keyframes` animation on a `::before` pseudo-element of the image wrapper. The pseudo-element displays a translucent gradient that sweeps left-to-right.
**Rationale:** Pure CSS, no JS needed for the animation. Pseudo-elements avoid extra DOM nodes. The existing `.card-placeholder` already fills the image area — the shimmer just adds motion on top of it.
**Alternatives considered:**
- SVG animated placeholder: More complex markup, no visual benefit for a simple shimmer.
- JS-driven animation (`requestAnimationFrame`): Heavier, no benefit over CSS keyframes.
- `<canvas>` BlurHash: Requires a build-time hash generation pipeline — overkill for a shimmer.
### 2. Image starts hidden, fades in on `load` event
**Decision:** The `<img>` element renders with `opacity: 0` (via a CSS class like `.img-loading`). A small inline `<script>` listens for the `load` event on each image and removes the loading class, triggering a CSS `opacity` transition to fade in. The shimmer placeholder sits behind the image and is naturally hidden once the image becomes opaque.
**Rationale:** The `load` event is the reliable signal that the image is ready to display. `opacity` transition is GPU-composited and smooth. No layout shift because the image dimensions are fixed by the container (`.card-media img` has `height: 180px; object-fit: cover`).
**Implementation detail:** Images may already be cached by the browser (instant load). The script must handle the case where `img.complete && img.naturalWidth > 0` on page load — immediately remove the loading class without waiting for the `load` event.
**Alternatives considered:**
- `IntersectionObserver` to defer `src` assignment: Already handled by `loading="lazy"` — doubling up adds complexity for no benefit.
- CSS-only `:not([src])` or `content-visibility`: No reliable CSS-only way to detect "image has loaded."
### 3. Single reusable wrapper pattern
**Decision:** Wrap each `<img>` in a container element with class `.img-shimmer-wrap`. This wrapper gets the shimmer pseudo-element and positions the image on top. The same wrapper pattern is used for both card thumbnails and blog featured images.
**Rationale:** One CSS class, one script, works everywhere. The wrapper inherits the size from its parent (`.card-media` for cards, inline styles for blog images), so no per-surface sizing logic is needed.
### 4. Error handling: keep placeholder visible
**Decision:** On image `error` event, the loading class stays on (image remains `opacity: 0`), so the shimmer/placeholder remains visible. The shimmer animation stops (replaced by a static placeholder state) to avoid an endlessly-animating error state.
**Rationale:** A static placeholder is better UX than a broken image icon or an infinite shimmer. The user sees a clean grey block instead of a broken experience.
### 5. Reduced motion: static placeholder, no shimmer sweep
**Decision:** The existing global CSS rule (`@media (prefers-reduced-motion: reduce)`) already suppresses all animations and transitions to near-zero duration. The shimmer animation inherits this behavior automatically — no additional media query needed.
**Rationale:** The global rule (`animation-duration: 0.001ms !important`) already covers all keyframe animations. The shimmer becomes a static gradient overlay, which is still a valid loading indicator without motion.
## Risks / Trade-offs
- **[Layout shift on blog images]** → Blog featured images use inline `max-height: 420px` but no explicit `width`/`height` attributes, which could cause minor CLS. Mitigation: the wrapper inherits `width: 100%` and `max-height: 420px` from the existing inline styles, so the placeholder reserves the correct space. No change in CLS from current behavior.
- **[Already-cached images flash shimmer]** → If images are in the browser cache, they load near-instantly, but the shimmer might flicker briefly. Mitigation: the script checks `img.complete` on DOM ready and immediately reveals cached images — no visible shimmer for cached images.
- **[Increased CSS size]** → The shimmer keyframes and wrapper styles add ~30 lines to `global.css`. Mitigation: negligible impact on a static site with a single CSS file.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,109 @@
## 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 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 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:**
- 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:** 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.
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
**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" / "Listen on 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.
### 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:** "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.
## Risks / Trade-offs
- **[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.
- **[No-JS fallback]** → If JavaScript is disabled, the `<button>` cards won't open a modal. Mitigation: Include a `<noscript>` hint or make the card a link as a no-JS fallback (progressive enhancement). The detail pages (`/videos/[id]`, `/podcast/[id]`) always exist as a non-JS path.
- **[Content Security Policy]** → Embedding YouTube/Spotify requires `frame-src` to allow these domains. Mitigation: verify CSP headers (if any) allow `youtube.com` and `spotify.com` iframe sources. Currently no CSP is set, so no issue.
- **[Modal on mobile]** → Full-screen modal on small viewports may feel heavy. Mitigation: on mobile breakpoints, the modal takes full viewport width with reduced padding, and the embed scales responsively via `aspect-ratio` or percentage-based sizing.

View File

@@ -0,0 +1,37 @@
## 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)
- "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.
- **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 ("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
- **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.
- **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.

View File

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

View File

@@ -0,0 +1,45 @@
## MODIFIED Requirements
### Requirement: Standard card information architecture
All content cards rendered by the site MUST use a standardized layout so cards across different surfaces look consistent.
The standard card layout MUST be:
- featured image displayed prominently at the top (when available)
- title
- summary/excerpt text, trimmed to a fixed maximum length
- footer row showing:
- publish date on the left
- views when available (if omitted, the footer MUST still render cleanly)
- the content source label (e.g., `youtube`, `podcast`, `blog`)
If a field is not available (for example, views for some sources), the card MUST still render cleanly with that field omitted.
For content cards with source `youtube` or `podcast`, the card MUST render as a clickable element that opens a media modal dialog instead of navigating to an external URL. The card MUST NOT render as an outbound `<a>` link for these sources.
For content cards with other sources (e.g., `blog`, `instagram`), the card MUST continue to render as a navigational link (the existing behavior).
The card element for modal-trigger cards MUST carry the content item's data (id, source, url, title, summary, publishedAt, thumbnailUrl, views) as `data-*` attributes so the modal script can access them.
#### Scenario: Card renders with all fields
- **WHEN** a content item has an image, title, summary, publish date, views, and source
- **THEN** the card renders those fields in the standard card layout order
#### Scenario: Card renders without views
- **WHEN** a content item has no views data
- **THEN** the card renders the footer bar with date + source and omits views without breaking the layout
#### Scenario: Card renders without featured image
- **WHEN** a content item has no featured image
- **THEN** the card renders a placeholder media area and still renders the remaining fields
#### Scenario: YouTube video card opens modal
- **WHEN** a user clicks a content card with source `youtube`
- **THEN** a media modal dialog opens with the video's embedded player and metadata instead of navigating to YouTube
#### Scenario: Podcast card opens modal
- **WHEN** a user clicks a content card with source `podcast`
- **THEN** a media modal dialog opens with the episode's embedded player (or metadata link) instead of navigating to the podcast platform
#### Scenario: Blog card still navigates
- **WHEN** a user clicks a content card with source `blog`
- **THEN** the card navigates to the blog post as an internal link (existing behavior, unaffected)

View File

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

@@ -0,0 +1,102 @@
## 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}`.
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`
## MODIFIED Requirements
### Requirement: Minimum required properties
Every tracked click event MUST include, at minimum:
- `target_id`
- `placement`
For links, the event MUST also include:
- `target_url` (or a stable target identifier that can be mapped to a URL)
For content-related links (clickables representing a specific piece of content), the event MUST also include:
- `title` (human-readable content title)
- `type` (content type identifier)
The `type` value MUST be one of:
- `video`
- `podcast_episode`
- `blog_post`
- `blog_page`
For non-link clickables that trigger in-page actions (e.g., modal openers), the event MUST also include:
- `title` (human-readable content title)
- `type` (content type identifier)
- `source` (content source identifier)
#### Scenario: Tracking a content card click
- **WHEN** a user clicks a content card link
- **THEN** the emitted event includes `target_id`, `placement`, and `target_url`
#### Scenario: Tracking a content link includes title and type
- **WHEN** a user clicks a content-related link that represents a specific content item
- **THEN** the emitted event includes `target_id`, `placement`, `target_url`, `title`, and `type`
#### Scenario: Tracking a modal-trigger card includes title, type, and source
- **WHEN** a user clicks a content card that opens a media modal instead of navigating
- **THEN** the emitted event includes `target_id`, `placement`, `title`, `type`, and `source` (no `target_url` since the user stays on-page)

View File

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

@@ -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

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

View File

@@ -17,6 +17,6 @@
## 4. Verification ## 4. Verification
- [ ] 4.1 Verify service worker registers in production build and does not register in dev - [x] 4.1 Verify service worker registers in production build and does not register in dev
- [ ] 4.2 Verify repeat navigation and asset loads hit cache (Chrome DevTools Application tab) - [x] 4.2 Verify repeat navigation and asset loads hit cache (Chrome DevTools Application tab)
- [ ] 4.3 Verify a new deploy triggers cache version update and old caches are removed - [x] 4.3 Verify a new deploy triggers cache version update and old caches are removed

View File

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

View File

@@ -0,0 +1,106 @@
## Context
The site currently uses a single dark theme defined via CSS variables in `site/public/styles/global.css` (e.g., `--bg0`, `--bg1`, `--fg`, `--muted`, `--stroke`, `--accent`). There is no existing theme selection mechanism (no `data-theme` attribute, no persisted preference).
The site shell uses a sticky `.site-header` and a per-page `.subnav` row (when present). A theme switcher notch must be fixed-positioned such that it does not overlap the header and leaves enough space for the subnav region.
## Goals / Non-Goals
**Goals:**
- Provide three themes: `dark`, `light`, `high-contrast`.
- Allow switching themes via a floating notch on the right side of the screen positioned below the primary nav bar and not overlapping the subnav area.
- Make switching feel premium:
- hover animation on the notch
- smooth theme transitions (without an abrupt flash)
- Ensure accessibility:
- keyboard operable
- visible focus
- respects `prefers-reduced-motion`
- high contrast theme is meaningfully higher contrast, not just a color swap
- Persist the user's selection across page loads.
**Non-Goals:**
- Rebuild the entire visual system or rewrite all CSS to a design-token framework.
- PWA theming (manifest/theme-color) beyond what is required to implement UI themes.
- Adding user accounts or server-side persistence of theme preference.
## Decisions
### 1. Theme selection mechanism: `data-theme` on `<html>`
Use `document.documentElement.dataset.theme = "dark" | "light" | "high-contrast"`.
Rationale:
- Works cleanly with CSS variables.
- Scopes theme styles across the entire page without specificity fights.
Alternatives considered:
- Adding theme classes to `body` (works, but html-scoped variables are simpler for form control theming).
### 2. Token strategy: override existing CSS variables per theme
Keep the existing variable names and provide theme-specific overrides:
- `:root` remains the default (dark)
- `html[data-theme="light"] { ... }`
- `html[data-theme="high-contrast"] { ... }`
Rationale:
- Minimizes churn in existing CSS.
- Enables incremental migration of any remaining hard-coded colors to tokens.
### 3. Default theme resolution order
On first load, resolve the active theme in this order:
1) stored user preference (`localStorage.theme`)
2) forced colors / high-contrast OS mode (if detected) -> `high-contrast`
3) system color scheme -> `light` if `prefers-color-scheme: light`, else `dark`
Rationale:
- User choice wins.
- If the user is in a forced/high-contrast environment, defaulting to high-contrast aligns with accessibility intent.
### 4. Prevent flash of wrong theme with a tiny head script
Insert a small inline script in the document `<head>` that sets `data-theme` before first paint.
Rationale:
- Avoids "flash" where the page renders in dark before switching to light/high-contrast.
Trade-off:
- Inline scripts can constrain future CSP hardening; keep script small and self-contained.
### 5. Smooth transitions without animating on every page load
Use a transient attribute/class (e.g., `data-theme-transition="on"`) only during user-initiated theme changes.
Implementation shape:
- When switching: set `data-theme-transition="on"`, update `data-theme`, then remove after ~250ms.
- CSS applies transitions for color/background/border/shadow only when the attribute is present.
Rationale:
- Avoids "everything animates" feeling during initial load.
- Avoids subtle jank on navigation.
### 6. Notch UI: fixed-position, expands on hover/focus
Implement the switcher as a fixed-position control at the right edge:
- Default collapsed: a small vertical tab.
- On `:hover` and `:focus-within`: expands into a small panel exposing the three theme options.
Accessibility decisions:
- Use a real `<button>` to open/close (for touch) OR a `<fieldset role="radiogroup">` with three radio-like buttons.
- Ensure it is reachable via keyboard and has clear `aria-label`s.
Placement decisions:
- Use a CSS variable `--theme-notch-top` to position it.
- A small inline script computes this based on `.site-header` height and, if a `.subnav` exists near the top, positions below it.
### 7. High Contrast theme semantics
The high-contrast theme will be a dedicated palette (not only increased brightness) with:
- strong background/foreground contrast
- high visibility focus ring
- more assertive stroke borders
Additionally, handle OS forced-colors mode:
- In `@media (forced-colors: active)`, prefer system colors and avoid gradients that reduce clarity.
## Risks / Trade-offs
- **[Notch overlaps content]** -> compute top offset from header/subnav; provide safe-area padding; add responsive rules for small viewports.
- **[Theme transitions reduce readability]** -> scope transitions to a short window and limit properties; disable via `prefers-reduced-motion`.
- **[High contrast breaks brand feel]** -> keep layout/typography unchanged and only adjust palette and borders.
- **[CSP constraints]** -> keep head script minimal and consider moving to an external script if CSP hardening becomes a priority.

View File

@@ -0,0 +1,27 @@
## Why
Add modern theming controls (dark/light/high-contrast) to improve accessibility and give the site a polished, customizable "WOW" experience.
## What Changes
- Add three user-selectable themes: **Dark**, **Light**, and **High Contrast**.
- Add a floating theme switcher "notch" on the right edge of the screen:
- positioned just below the primary nav bar
- leaves enough vertical space for secondary navigation
- hover state includes a tasteful animation
- Theme switching uses a smooth transition (not an abrupt flash).
## Capabilities
### New Capabilities
- `site-theming`: Theme tokens and a theme application mechanism that can switch between Dark/Light/High Contrast across the site.
- `theme-switcher-notch`: A floating, accessible UI control (right-side notch) that lets the user switch themes.
### Modified Capabilities
- `wcag-responsive-ui`: Extend accessibility baseline to include theme switching requirements (keyboard, focus, reduced motion) and ensure High Contrast theme is supported.
## Impact
- Affected UI/CSS: global design tokens (CSS variables), background layers, card/CTA styling, focus styling.
- Affected layout: a new floating notch component that must not overlap navigation across breakpoints.
- Affected UX/accessibility: keyboard navigation and motion preferences during theme transitions.

View File

@@ -0,0 +1,55 @@
## ADDED Requirements
### Requirement: Site themes
The site MUST support three themes:
- `dark`
- `light`
- `high-contrast`
Themes MUST be applied by setting a `data-theme` attribute on the root document element (`<html>`).
#### Scenario: Dark theme active
- **WHEN** `data-theme="dark"` is set on `<html>`
- **THEN** the site's background, text, and component styling reflect the dark palette
#### Scenario: Light theme active
- **WHEN** `data-theme="light"` is set on `<html>`
- **THEN** the site's background, text, and component styling reflect the light palette
#### Scenario: High contrast theme active
- **WHEN** `data-theme="high-contrast"` is set on `<html>`
- **THEN** the site uses a high-contrast palette with a clearly visible focus ring and high-contrast borders
### Requirement: Theme persistence
The site MUST persist the user's theme selection so it is retained across page loads and navigations.
Persistence MUST be stored locally in the browser (e.g., localStorage).
#### Scenario: Theme persists across reload
- **WHEN** the user selects `light` theme and reloads the page
- **THEN** the `light` theme remains active
### Requirement: Default theme selection
If the user has not explicitly selected a theme, the site MUST choose a default theme using environment signals.
Default selection order:
1) If forced colors / high-contrast mode is active, default to `high-contrast`
2) Else if the system prefers light color scheme, default to `light`
3) Else default to `dark`
#### Scenario: No stored preference uses system settings
- **WHEN** the user has no stored theme preference
- **THEN** the site selects a default theme based on forced-colors and prefers-color-scheme
### Requirement: Theme switching transition
Theme changes initiated by the user MUST transition smoothly.
The transition MUST be disabled or substantially reduced when `prefers-reduced-motion: reduce` is set.
#### Scenario: Smooth transition on switch
- **WHEN** the user switches from `dark` to `light` theme
- **THEN** theme-affecting properties transition smoothly instead of abruptly switching
#### Scenario: Reduced motion disables theme animation
- **WHEN** `prefers-reduced-motion: reduce` is set and the user switches theme
- **THEN** the theme change occurs without noticeable animation

View File

@@ -0,0 +1,42 @@
## ADDED Requirements
### Requirement: Floating theme switcher notch
The site MUST provide a floating theme switcher control anchored to the right side of the viewport.
The control MUST be positioned below the primary navigation bar and MUST leave sufficient vertical space for secondary navigation.
#### Scenario: Notch positioned below header
- **WHEN** the page loads
- **THEN** the theme switcher notch is visible on the right side and does not overlap the sticky header or sub-navigation
### Requirement: Notch interaction and animation
The notch MUST provide a hover affordance (a small, tasteful animation) that indicates it is interactive.
The hover animation MUST be disabled or substantially reduced under `prefers-reduced-motion: reduce`.
#### Scenario: Hover animation present
- **WHEN** a pointer user hovers the notch
- **THEN** the notch animates in a way that suggests it can be expanded or interacted with
#### Scenario: Reduced motion disables hover animation
- **WHEN** `prefers-reduced-motion: reduce` is set
- **THEN** hovering the notch does not trigger a noticeable animation
### Requirement: Theme selection UI
The notch MUST expose the three theme options (`dark`, `light`, `high-contrast`) and allow the user to select one.
The control MUST be keyboard accessible:
- it MUST be reachable via `Tab`
- it MUST have a visible focus indicator
- selection MUST be possible using keyboard input
#### Scenario: Keyboard selects theme
- **WHEN** a keyboard user focuses the notch and selects `high-contrast`
- **THEN** the site updates to the `high-contrast` theme and the selection is persisted
### Requirement: Accessibility labels
The notch and theme options MUST have accessible labels.
#### Scenario: Screen reader announces theme switcher
- **WHEN** a screen reader user focuses the theme switcher control
- **THEN** it announces an appropriate label (e.g., "Theme" or "Theme switcher") and the currently selected theme

View File

@@ -0,0 +1,30 @@
## ADDED Requirements
### Requirement: Theme switching accessibility
Theme switching controls MUST be accessible and usable with keyboard and assistive technology.
The theme switcher control MUST:
- be reachable via keyboard navigation
- provide a visible focus indication
- expose an accessible name/label
- allow selecting any supported theme without requiring a pointer
#### Scenario: Theme switcher is keyboard reachable
- **WHEN** a keyboard user tabs through the page
- **THEN** the theme switcher notch receives focus and shows a visible focus indicator
#### Scenario: Theme switcher is labeled
- **WHEN** a screen reader user focuses the theme switcher
- **THEN** it announces a meaningful label and the current theme state
### Requirement: High contrast theme meets WCAG intent
The `high-contrast` theme MUST provide materially higher contrast than the default theme.
The theme MUST keep text readable and interactive affordances obvious, including:
- strong foreground/background contrast
- clearly visible focus ring
- strong borders on interactive elements
#### Scenario: High contrast theme improves readability
- **WHEN** the user enables `high-contrast` theme
- **THEN** primary text and secondary UI labels remain clearly readable and interactive elements are visually distinct

View File

@@ -0,0 +1,26 @@
## 1. Theme Tokens And Application
- [x] 1.1 Add `data-theme` overrides in `site/public/styles/global.css` for `light` and `high-contrast` (keep `:root` as default dark)
- [x] 1.2 Add `color-scheme` rules per theme so native form controls match (dark/light)
- [x] 1.3 Add theme initialization script in `site/src/layouts/BaseLayout.astro` to set theme before first paint (stored preference → forced colors/high contrast → prefers-color-scheme)
- [x] 1.4 Persist theme selection to localStorage and update `data-theme` on change
- [x] 1.5 Implement scoped smooth transitions for user-initiated theme changes (no global transition on initial load)
## 2. Theme Switcher Notch UI
- [x] 2.1 Add markup for a fixed-position right-side notch in `site/src/layouts/BaseLayout.astro`
- [x] 2.2 Implement notch positioning below `.site-header` and below `.subnav` when present (compute top offset; handle resize)
- [x] 2.3 Add notch hover animation (expand/slide) and ensure it feels intentional
- [x] 2.4 Add keyboard and screen reader support (label, focus styles, keyboard selection)
- [x] 2.5 Ensure notch does not overlap critical content on mobile (responsive rules; safe-area)
## 3. High Contrast Theme Verification
- [x] 3.1 Ensure high-contrast theme has strong fg/bg contrast, obvious focus ring, and strong strokes on interactive elements
- [x] 3.2 Add `@media (forced-colors: active)` adjustments to avoid illegible gradients and ensure system colors are respected
## 4. Verification
- [x] 4.1 Run `npm run build` and verify output HTML includes the head theme-init script
- [x] 4.2 Manual smoke test: switch themes on `/`, `/videos`, `/podcast`, `/blog` and verify persistence across reload
- [x] 4.3 Manual a11y checks: keyboard-only interaction, focus visibility, prefers-reduced-motion behavior

View File

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

View File

@@ -0,0 +1,62 @@
## Context
The site already supports theme selection via a floating notch and persists the user's choice using `localStorage` (key: `site.theme`). Theme selection affects the root document via `html[data-theme]` and is initialized before first paint via an inline head script.
Two gaps remain:
- Returning users should reliably see their last-selected theme even in environments where `localStorage` is unavailable.
- We need analytics to measure theme switcher usage and preferred themes.
The site uses Umami for analytics. Most interactions are tracked via `data-umami-event*` attributes, and runtime-only events use `window.umami.track(...)`.
## Goals / Non-Goals
**Goals:**
- Persist theme selection across visits with a robust fallback: `localStorage` primary, client-side cookie fallback.
- Apply the stored theme before first paint when possible.
- Emit a deterministic Umami event when a user changes theme via the theme notch.
**Non-Goals:**
- Server-side rendering of theme choice (the site is statically built; no request-time HTML variation).
- Tracking theme selection on initial page load (only user-initiated changes).
- Adding new UI beyond the existing theme notch.
## Decisions
1) Persistence mechanism and precedence
- **Decision**: Read theme preference in this order:
1. `localStorage` (`site.theme`)
2. Cookie (`site_theme`)
3. Environment signals (forced-colors, prefers-color-scheme)
- **Rationale**: `localStorage` is already in use and provides a stable primary store. Cookies provide a resilient fallback when storage access is blocked or throws.
- **Alternatives considered**:
- Cookie-only: simpler but unnecessary regression from existing behavior.
- URL param: not persistent and adds user-visible noise.
2) Cookie format and attributes
- **Decision**: Store `site_theme=<theme>` with `Max-Age=31536000`, `Path=/`, `SameSite=Lax`. Set `Secure` when running under HTTPS.
- **Rationale**: First-party cookie with long TTL provides continuity across visits. The cookie is readable from the inline head script for pre-paint initialization.
3) Analytics event shape
- **Decision**: Emit a custom Umami event via `window.umami.track("theme_switch", data)` only on user-initiated changes.
- **Event properties**:
- `target_id`: `theme.switch.<theme>`
- `placement`: `theme_notch`
- `theme`: new theme value (`dark` | `light` | `high-contrast`)
- `prev_theme`: previous theme value (same enum) when known
- **Rationale**: A dedicated event name makes reporting straightforward (no need to filter general `click`). Using `target_id`/`placement` keeps it compatible with the site's interaction taxonomy.
- **Alternatives considered**:
- Reuse `click` event: consistent, but mixes preference changes into general click reporting.
4) Avoid tracking initial theme restoration
- **Decision**: Do not emit `theme_switch` from the head theme-init script.
- **Rationale**: We want to measure explicit user interaction with the notch, not implicit restoration.
## Risks / Trade-offs
- Cookie and storage may both be blocked in restrictive environments → fallback to environment signals; no persistence.
- Umami may be disabled/unconfigured or not loaded at event time → guard with `typeof window.umami !== "undefined"` and keep behavior non-fatal.
- Using cookies introduces another persistence layer → must keep `localStorage` and cookie consistent on successful theme changes.

View File

@@ -0,0 +1,25 @@
## Why
Theme choice is part of a user's identity and comfort on the site; returning visitors should land in the theme they previously selected. We also need measurement to understand whether the theme switcher is being used and which themes are preferred.
## What Changes
- Persist the user's selected theme across visits so returning users see the last-selected theme immediately.
- Add Umami tracking for theme selection changes so theme switch usage can be measured and segmented.
- Improve robustness of persistence by supporting either localStorage or a client-side cookie (cookie fallback when localStorage is unavailable).
## Capabilities
### New Capabilities
- (none)
### Modified Capabilities
- `site-theming`: Extend theme persistence requirements to explicitly cover returning visits and define acceptable client-side persistence mechanisms / fallback behavior.
- `analytics-umami`: Add a custom event emitted from client-side code for theme selection changes (using Umami's JS API when needed).
- `interaction-tracking-taxonomy`: Define the theme selection event name and required event properties (at minimum `target_id` and `placement`, plus theme metadata).
## Impact
- Frontend: update theme switcher behavior in `site/src/layouts/BaseLayout.astro` (persistence/fallback and event emission).
- Analytics: new Umami event(s) added; dashboards/filters can segment by selected theme and placement.
- Specs: update the modified capabilities above to reflect the new requirements.

View File

@@ -0,0 +1,21 @@
## ADDED Requirements
### Requirement: Theme switch tracking event
When Umami is enabled, the site MUST emit a custom event when the user changes theme via the theme switcher UI.
The site MUST emit the event using Umami's JavaScript API (`umami.track(...)`) so runtime properties can be included.
The event name MUST be `theme_switch`.
The emitted event MUST include, at minimum:
- `target_id`
- `placement`
- `theme`
#### Scenario: Theme switch emits event
- **WHEN** a user selects `high-contrast` in the theme switcher notch
- **THEN** the site emits a `theme_switch` event with `theme=high-contrast` and a stable `target_id`
#### Scenario: Missing Umami does not break switching
- **WHEN** Umami is not configured or the Umami script is not present
- **THEN** theme switching and persistence still work and no browser error is thrown

View File

@@ -0,0 +1,23 @@
## ADDED Requirements
### Requirement: Theme switch event taxonomy
The tracking taxonomy MUST define an event for theme switching.
The event name MUST be `theme_switch`.
The `theme_switch` event MUST include, at minimum:
- `target_id`
- `placement`
- `theme`
The event SHOULD include `prev_theme` when available.
The taxonomy MUST define the `target_id` namespace for theme switching as:
- `theme.switch.<theme>`
The taxonomy MUST define the `placement` value for the theme switcher notch as:
- `theme_notch`
#### Scenario: Theme switch target_id is deterministic
- **WHEN** a user selects `light` theme using the theme notch
- **THEN** the event is emitted with `target_id=theme.switch.light` and `placement=theme_notch`

View File

@@ -0,0 +1,25 @@
## ADDED Requirements
### Requirement: Theme persistence works across visits with fallback
The site MUST persist the user's theme selection across visits so returning users see the last-selected theme.
The site MUST use client-side persistence and MUST support a fallback mechanism:
- Primary: `localStorage`
- Fallback: a client-side cookie
The effective theme selection order MUST be:
1) Stored theme in `localStorage` (if available)
2) Stored theme in a cookie (if localStorage is unavailable)
3) Default selection using environment signals
#### Scenario: LocalStorage persists across a later visit
- **WHEN** a user selects `light` theme and later returns to the site in the same browser
- **THEN** the site initializes in `light` theme before first paint
#### Scenario: Cookie fallback is used when localStorage is unavailable
- **WHEN** the browser environment blocks `localStorage` access and the user selects `dark` theme
- **THEN** the theme is persisted using a client-side cookie and is restored on the next visit
#### Scenario: No persistence available falls back to defaults
- **WHEN** both `localStorage` and cookie persistence are unavailable
- **THEN** the site falls back to default theme selection using environment signals

View File

@@ -0,0 +1,17 @@
## 1. Theme Persistence Across Visits
- [x] 1.1 Add cookie helpers in `site/src/layouts/BaseLayout.astro` to read/write a `site_theme` cookie (Path=/, SameSite=Lax, 1y TTL; Secure on HTTPS)
- [x] 1.2 Update the head theme-init script in `site/src/layouts/BaseLayout.astro` to prefer localStorage, then cookie, then environment signals
- [x] 1.3 Update the theme setter in `site/src/layouts/BaseLayout.astro` to keep localStorage and cookie in sync on user selection (cookie fallback when localStorage throws)
## 2. Umami Tracking For Theme Switch
- [x] 2.1 Define event emission on user-initiated theme changes using `window.umami.track("theme_switch", ...)` (guarded when Umami is missing)
- [x] 2.2 Use taxonomy fields for the event payload: `target_id=theme.switch.<theme>`, `placement=theme_notch`, include `theme` and `prev_theme` when available
- [x] 2.3 Ensure theme restoration on page load does NOT emit `theme_switch` (only explicit user interaction)
## 3. Verification
- [x] 3.1 Run `npm run build` and verify output includes cookie read fallback in the head init script
- [x] 3.2 Run `npm test` and ensure no new failures are introduced
- [x] 3.3 Manual: switch theme, reload, and confirm persistence; repeat with localStorage disabled to confirm cookie fallback

View File

@@ -17,6 +17,9 @@ 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`
- `theme_switch`
- 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 +30,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 +52,38 @@ 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: Theme switch tracking event
When Umami is enabled, the site MUST emit a custom event when the user changes theme via the theme switcher UI.
The site MUST emit the event using Umami's JavaScript API (`umami.track(...)`) so runtime properties can be included.
The event name MUST be `theme_switch`.
The emitted event MUST include, at minimum:
- `target_id`
- `placement`
- `theme`
#### Scenario: Theme switch emits event
- **WHEN** a user selects `high-contrast` in the theme switcher notch
- **THEN** the site emits a `theme_switch` event with `theme=high-contrast` and a stable `target_id`
#### Scenario: Missing Umami does not break switching
- **WHEN** Umami is not configured or the Umami script is not present
- **THEN** theme switching and persistence still work and no browser error is thrown
### 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

@@ -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. 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: 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 - title
- summary/excerpt text, trimmed to a fixed maximum length - summary/excerpt text, trimmed to a fixed maximum length
- footer row showing: - footer row showing:
@@ -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
@@ -30,3 +36,22 @@ If a field is not available (for example, views for some sources), the card MUST
- **WHEN** a content item has no featured image - **WHEN** a content item has no featured image
- **THEN** the card renders a placeholder media area and still renders the remaining fields - **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
#### 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

@@ -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

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,9 +123,35 @@ 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.
#### Scenario: Tracking includes only categorical metadata #### Scenario: Tracking includes only categorical metadata
- **WHEN** tracking metadata is defined for a clickable item - **WHEN** tracking metadata is defined for a clickable item
- **THEN** it contains only categorical identifiers (ids, placements, domains) and does not include user-provided content - **THEN** it contains only categorical identifiers (ids, placements, domains) and does not include user-provided content
### Requirement: Theme switch event taxonomy
The tracking taxonomy MUST define an event for theme switching.
The event name MUST be `theme_switch`.
The `theme_switch` event MUST include, at minimum:
- `target_id`
- `placement`
- `theme`
The event MUST include `prev_theme` when it is available.
The taxonomy MUST define the `target_id` namespace for theme switching as:
- `theme.switch.<theme>`
The taxonomy MUST define the `placement` value for the theme switcher notch as:
- `theme_notch`
#### Scenario: Theme switch target_id is deterministic
- **WHEN** a user selects `light` theme using the theme notch
- **THEN** the event is emitted with `target_id=theme.switch.light` and `placement=theme_notch`

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

@@ -0,0 +1,48 @@
## Purpose
Improve repeat-visit performance and reduce network usage by using a service worker to pre-cache critical shell assets and apply safe runtime caching strategies.
## Requirements
### Requirement: Service Worker registration
The site SHALL register a Service Worker on supported browsers when running in production (HTTPS), scoped to the site root so it can control all site pages.
#### Scenario: Production registration
- **WHEN** a user loads any page in a production environment
- **THEN** the site registers a service worker at `/sw.js` with scope `/`
#### Scenario: Development does not register
- **WHEN** a user loads any page in a local development environment
- **THEN** the site does not register a service worker
### Requirement: Pre-cache critical site shell assets
The Service Worker SHALL pre-cache a set of critical static assets required to render the site shell quickly on repeat visits.
#### Scenario: Pre-cache on install
- **WHEN** the service worker is installed
- **THEN** it caches the configured site shell assets in a versioned cache
### Requirement: Runtime caching for media assets
The Service Worker SHALL use runtime caching for media assets (for example images) to reduce repeat network fetches, while ensuring content can refresh.
#### Scenario: Cache-first for images
- **WHEN** a user requests an image resource
- **THEN** the service worker serves the cached image when available, otherwise fetches from the network and stores the response in the media cache
#### Scenario: Enforce cache size bounds
- **WHEN** the number of cached media items exceeds the configured maximum
- **THEN** the service worker evicts older entries to stay within the bound
### Requirement: Navigation requests avoid indefinite staleness
The Service Worker MUST NOT serve stale HTML indefinitely for navigation requests.
#### Scenario: Network-first navigation
- **WHEN** a user navigates to a page route (a document navigation request)
- **THEN** the service worker attempts to fetch from the network first and falls back to a cached response if the network is unavailable
### Requirement: Safe updates and cache cleanup
The Service Worker SHALL use versioned caches and remove old caches during activation to ensure updated assets are used after a new deploy.
#### Scenario: Activate new version and clean old caches
- **WHEN** a new service worker version activates
- **THEN** it deletes caches from older versions and begins using the current versioned caches

View File

@@ -0,0 +1,83 @@
## Purpose
Define site-wide theme support (dark, light, high-contrast) using CSS tokens and an application mechanism that can switch across the entire UI.
## Requirements
### Requirement: Site themes
The site MUST support three themes:
- `dark`
- `light`
- `high-contrast`
Themes MUST be applied by setting a `data-theme` attribute on the root document element (`<html>`).
#### Scenario: Dark theme active
- **WHEN** `data-theme="dark"` is set on `<html>`
- **THEN** the site's background, text, and component styling reflect the dark palette
#### Scenario: Light theme active
- **WHEN** `data-theme="light"` is set on `<html>`
- **THEN** the site's background, text, and component styling reflect the light palette
#### Scenario: High contrast theme active
- **WHEN** `data-theme="high-contrast"` is set on `<html>`
- **THEN** the site uses a high-contrast palette with a clearly visible focus ring and high-contrast borders
### Requirement: Theme persistence
The site MUST persist the user's theme selection so it is retained across page loads and navigations.
Persistence MUST be stored locally in the browser (e.g., localStorage).
#### Scenario: Theme persists across reload
- **WHEN** the user selects `light` theme and reloads the page
- **THEN** the `light` theme remains active
### Requirement: Theme persistence works across visits with fallback
The site MUST persist the user's theme selection across visits so returning users see the last-selected theme.
The site MUST use client-side persistence and MUST support a fallback mechanism:
- Primary: `localStorage`
- Fallback: a client-side cookie
The effective theme selection order MUST be:
1) Stored theme in `localStorage` (if available)
2) Stored theme in a cookie (if localStorage is unavailable)
3) Default selection using environment signals
#### Scenario: LocalStorage persists across a later visit
- **WHEN** a user selects `light` theme and later returns to the site in the same browser
- **THEN** the site initializes in `light` theme before first paint
#### Scenario: Cookie fallback is used when localStorage is unavailable
- **WHEN** the browser environment blocks `localStorage` access and the user selects `dark` theme
- **THEN** the theme is persisted using a client-side cookie and is restored on the next visit
#### Scenario: No persistence available falls back to defaults
- **WHEN** both `localStorage` and cookie persistence are unavailable
- **THEN** the site falls back to default theme selection using environment signals
### Requirement: Default theme selection
If the user has not explicitly selected a theme, the site MUST choose a default theme using environment signals.
Default selection order:
1) If forced colors / high-contrast mode is active, default to `high-contrast`
2) Else if the system prefers light color scheme, default to `light`
3) Else default to `dark`
#### Scenario: No stored preference uses system settings
- **WHEN** the user has no stored theme preference
- **THEN** the site selects a default theme based on forced-colors and prefers-color-scheme
### Requirement: Theme switching transition
Theme changes initiated by the user MUST transition smoothly.
The transition MUST be disabled or substantially reduced when `prefers-reduced-motion: reduce` is set.
#### Scenario: Smooth transition on switch
- **WHEN** the user switches from `dark` to `light` theme
- **THEN** theme-affecting properties transition smoothly instead of abruptly switching
#### Scenario: Reduced motion disables theme animation
- **WHEN** `prefers-reduced-motion: reduce` is set and the user switches theme
- **THEN** the theme change occurs without noticeable animation

View File

@@ -0,0 +1,46 @@
## Purpose
Define the requirements for a floating, accessible theme switcher notch anchored to the right side of the viewport.
## Requirements
### Requirement: Floating theme switcher notch
The site MUST provide a floating theme switcher control anchored to the right side of the viewport.
The control MUST be positioned below the primary navigation bar and MUST leave sufficient vertical space for secondary navigation.
#### Scenario: Notch positioned below header
- **WHEN** the page loads
- **THEN** the theme switcher notch is visible on the right side and does not overlap the sticky header or sub-navigation
### Requirement: Notch interaction and animation
The notch MUST provide a hover affordance (a small, tasteful animation) that indicates it is interactive.
The hover animation MUST be disabled or substantially reduced under `prefers-reduced-motion: reduce`.
#### Scenario: Hover animation present
- **WHEN** a pointer user hovers the notch
- **THEN** the notch animates in a way that suggests it can be expanded or interacted with
#### Scenario: Reduced motion disables hover animation
- **WHEN** `prefers-reduced-motion: reduce` is set
- **THEN** hovering the notch does not trigger a noticeable animation
### Requirement: Theme selection UI
The notch MUST expose the three theme options (`dark`, `light`, `high-contrast`) and allow the user to select one.
The control MUST be keyboard accessible:
- it MUST be reachable via `Tab`
- it MUST have a visible focus indicator
- selection MUST be possible using keyboard input
#### Scenario: Keyboard selects theme
- **WHEN** a keyboard user focuses the notch and selects `high-contrast`
- **THEN** the site updates to the `high-contrast` theme and the selection is persisted
### Requirement: Accessibility labels
The notch and theme options MUST have accessible labels.
#### Scenario: Screen reader announces theme switcher
- **WHEN** a screen reader user focuses the theme switcher control
- **THEN** it announces an appropriate label (e.g., "Theme" or "Theme switcher") and the currently selected theme

View File

@@ -69,3 +69,31 @@ The site MUST ensure text remains readable:
- **WHEN** a user navigates between pages - **WHEN** a user navigates between pages
- **THEN** typography (font family and basic scale) remains consistent - **THEN** typography (font family and basic scale) remains consistent
### Requirement: Theme switching accessibility
Theme switching controls MUST be accessible and usable with keyboard and assistive technology.
The theme switcher control MUST:
- be reachable via keyboard navigation
- provide a visible focus indication
- expose an accessible name/label
- allow selecting any supported theme without requiring a pointer
#### Scenario: Theme switcher is keyboard reachable
- **WHEN** a keyboard user tabs through the page
- **THEN** the theme switcher notch receives focus and shows a visible focus indicator
#### Scenario: Theme switcher is labeled
- **WHEN** a screen reader user focuses the theme switcher
- **THEN** it announces a meaningful label and the current theme state
### Requirement: High contrast theme meets WCAG intent
The `high-contrast` theme MUST provide materially higher contrast than the default theme.
The theme MUST keep text readable and interactive affordances obvious, including:
- strong foreground/background contrast
- clearly visible focus ring
- strong borders on interactive elements
#### Scenario: High contrast theme improves readability
- **WHEN** the user enables `high-contrast` theme
- **THEN** primary text and secondary UI labels remain clearly readable and interactive elements are visually distinct

View File

@@ -1,12 +1,7 @@
#!/usr/bin/env sh #!/usr/bin/env sh
set -eu
cd "$(dirname "$0")/.." ## Fetch content, build the image, clear the cache and restart the container
docker run --rm -it --network container:$(docker compose ps -q redis) -v "${PWD}:/usr/src/app" -w /usr/src/app/site -e CACHE_REDIS_URL="redis://127.0.0.1:6379/0" node:24-alpine npm run fetch-content
echo "[refresh] pulling latest image" docker compose --env-file ../site/.env -f ../docker-compose.yml build
docker compose -f deploy/docker-compose.prod.yml pull docker run --rm -it --network container:$(docker compose ps -q redis) -v "${PWD}:/usr/src/app" -w /usr/src/app/site -e CACHE_REDIS_URL="redis://127.0.0.1:6379/0" node:24-alpine npm run cache:clear
docker compose up -d
echo "[refresh] restarting service (no build)"
docker compose -f deploy/docker-compose.prod.yml up -d --no-build
echo "[refresh] done"

View File

@@ -5,6 +5,11 @@ PUBLIC_SITE_URL=https://example.com
PUBLIC_UMAMI_SCRIPT_URL=https://analytics.example.com/script.js PUBLIC_UMAMI_SCRIPT_URL=https://analytics.example.com/script.js
PUBLIC_UMAMI_WEBSITE_ID=00000000-0000-0000-0000-000000000000 PUBLIC_UMAMI_WEBSITE_ID=00000000-0000-0000-0000-000000000000
# Service worker toggle (public, build-time). Use to enable in prod/staging.
# - In `npm run dev`, service workers are not registered regardless.
# - In `npm run build`/`preview`, SW is registered only if this is "true".
PUBLIC_ENABLE_SW=true
# Content ingestion configuration (used by scripts) # Content ingestion configuration (used by scripts)
YOUTUBE_CHANNEL_ID=UCxxxxxxxxxxxxxxxxxxxxxx YOUTUBE_CHANNEL_ID=UCxxxxxxxxxxxxxxxxxxxxxx
YOUTUBE_API_KEY= YOUTUBE_API_KEY=

2
site/.gitignore vendored
View File

@@ -22,3 +22,5 @@ pnpm-debug.log*
# jetbrains setting folder # jetbrains setting folder
.idea/ .idea/
nul

View File

@@ -1,5 +1,5 @@
{ {
"generatedAt": "2026-02-10T09:28:27.367Z", "generatedAt": "2026-02-10T21:36:25.766Z",
"items": [ "items": [
{ {
"id": "gPGbtfQdaw4", "id": "gPGbtfQdaw4",
@@ -10,7 +10,7 @@
"publishedAt": "2026-02-08T19:57:08.000Z", "publishedAt": "2026-02-08T19:57:08.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/gPGbtfQdaw4/hqdefault.jpg", "thumbnailUrl": "https://i.ytimg.com/vi/gPGbtfQdaw4/hqdefault.jpg",
"metrics": { "metrics": {
"views": 42 "views": 63
} }
}, },
{ {
@@ -22,7 +22,7 @@
"publishedAt": "2026-02-05T05:53:25.000Z", "publishedAt": "2026-02-05T05:53:25.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/aesTuu2nS-I/hqdefault.jpg", "thumbnailUrl": "https://i.ytimg.com/vi/aesTuu2nS-I/hqdefault.jpg",
"metrics": { "metrics": {
"views": 147 "views": 148
} }
}, },
{ {
@@ -34,7 +34,7 @@
"publishedAt": "2026-02-05T04:31:18.000Z", "publishedAt": "2026-02-05T04:31:18.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/9t8cBpZLHUo/hqdefault.jpg", "thumbnailUrl": "https://i.ytimg.com/vi/9t8cBpZLHUo/hqdefault.jpg",
"metrics": { "metrics": {
"views": 328 "views": 336
} }
}, },
{ {
@@ -46,7 +46,7 @@
"publishedAt": "2026-01-29T13:54:28.000Z", "publishedAt": "2026-01-29T13:54:28.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/71S5viSJG20/hqdefault.jpg", "thumbnailUrl": "https://i.ytimg.com/vi/71S5viSJG20/hqdefault.jpg",
"metrics": { "metrics": {
"views": 49 "views": 50
} }
}, },
{ {
@@ -118,7 +118,7 @@
"publishedAt": "2026-01-20T17:02:14.000Z", "publishedAt": "2026-01-20T17:02:14.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/zR9Ey8DjG5s/hqdefault.jpg", "thumbnailUrl": "https://i.ytimg.com/vi/zR9Ey8DjG5s/hqdefault.jpg",
"metrics": { "metrics": {
"views": 24 "views": 25
} }
}, },
{ {
@@ -293,7 +293,7 @@
"publishedAt": "2026-01-11T22:15:03.000Z", "publishedAt": "2026-01-11T22:15:03.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/XsJCIeqFWCY/hqdefault.jpg", "thumbnailUrl": "https://i.ytimg.com/vi/XsJCIeqFWCY/hqdefault.jpg",
"metrics": { "metrics": {
"views": 5 "views": 7
} }
}, },
{ {
@@ -303,7 +303,8 @@
"title": "E43. US History Understanding This Country | Children's Crusade & the Civil Rights Act of 1964: Turning Protest into Law", "title": "E43. US History Understanding This Country | Children's Crusade & the Civil Rights Act of 1964: Turning Protest into Law",
"summary": "Dive into the pivotal summer of 1963 in this episode of US History - Understanding This Country, hosted by Santhosh Janardhanan. Explore the Birmingham Campaign's bold Project C, where brave children faced fire hoses and police dogs in the…", "summary": "Dive into the pivotal summer of 1963 in this episode of US History - Understanding This Country, hosted by Santhosh Janardhanan. Explore the Birmingham Campaign's bold Project C, where brave children faced fire hoses and police dogs in the…",
"publishedAt": "2026-01-24T03:17:34.000Z", "publishedAt": "2026-01-24T03:17:34.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/114471652/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2026-0-24%2F1126c986-2549-be18-ac72-c68f016c3380.mp3"
}, },
{ {
"id": "90b42ea8-16e9-4374-8043-858c5c04db3f", "id": "90b42ea8-16e9-4374-8043-858c5c04db3f",
@@ -312,7 +313,8 @@
"title": "E42. US History Understanding This Country | Civil Rights Movement from Courtrooms to the Streets", "title": "E42. US History Understanding This Country | Civil Rights Movement from Courtrooms to the Streets",
"summary": "Episode 42 is where the Civil Rights Movement shifts from courtrooms to the streets. After legal wins like Brown v. Board of Education, activists and students push for real change in everyday life-at lunch counters, bus stations, and on in…", "summary": "Episode 42 is where the Civil Rights Movement shifts from courtrooms to the streets. After legal wins like Brown v. Board of Education, activists and students push for real change in everyday life-at lunch counters, bus stations, and on in…",
"publishedAt": "2026-01-16T19:59:44.000Z", "publishedAt": "2026-01-16T19:59:44.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/114108808/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2026-0-16%2Ffbc30f2a-076c-244c-8edc-fe847073291a.mp3"
}, },
{ {
"id": "9900f1c9-315b-42ba-991f-c31241bd9b55", "id": "9900f1c9-315b-42ba-991f-c31241bd9b55",
@@ -321,7 +323,8 @@
"title": "Episode 41a (Recap) - US History Podcast CatchUp: From Colonization to the Early Civil Rights Movement", "title": "Episode 41a (Recap) - US History Podcast CatchUp: From Colonization to the Early Civil Rights Movement",
"summary": "Im back - and I owe you an apology. I went AWOL after September for personal reasons, but US History - Understanding This Country by Irregular Mind is back in the groove. In this recap episode, I quickly bring you up to speed on everythin…", "summary": "Im back - and I owe you an apology. I went AWOL after September for personal reasons, but US History - Understanding This Country by Irregular Mind is back in the groove. In this recap episode, I quickly bring you up to speed on everythin…",
"publishedAt": "2026-01-15T16:24:16.000Z", "publishedAt": "2026-01-15T16:24:16.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/114047090/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2026-0-15%2F745dfc28-5bb8-7a31-045a-9b93871910e6.mp3"
}, },
{ {
"id": "9002e7f6-8ecf-47e2-9590-ba4adfc5dc6d", "id": "9002e7f6-8ecf-47e2-9590-ba4adfc5dc6d",
@@ -330,7 +333,8 @@
"title": "E41. US History Understanding This Country | Civil Rights Beginnings: Brown, Parks & King", "title": "E41. US History Understanding This Country | Civil Rights Beginnings: Brown, Parks & King",
"summary": "Explore the early US civil rights movement: Brown v. Board of Education, Montgomery Bus Boycott, Little Rock Nine, Rosa Parks, Martin Luther King Jr., school integration, nonviolence, and resistance in the 1950s. Legal cases, social justic…", "summary": "Explore the early US civil rights movement: Brown v. Board of Education, Montgomery Bus Boycott, Little Rock Nine, Rosa Parks, Martin Luther King Jr., school integration, nonviolence, and resistance in the 1950s. Legal cases, social justic…",
"publishedAt": "2025-09-20T03:47:37.000Z", "publishedAt": "2025-09-20T03:47:37.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/108561883/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-8-20%2Fcb7f16fc-e9be-5a09-bc96-af307f4ff5fd.mp3"
}, },
{ {
"id": "96e72f96-fb71-4717-9047-120c6e32973a", "id": "96e72f96-fb71-4717-9047-120c6e32973a",
@@ -339,7 +343,8 @@
"title": "E40. US History Understanding This Country | Prosperity, TV, Rock n Roll", "title": "E40. US History Understanding This Country | Prosperity, TV, Rock n Roll",
"summary": "Step into the 1950s: GI Bill-fueled growth, Levittown suburbs, TV in every living room, and rock n roll teens—alongside redlining, poverty, and early sparks of Civil Rights. Booming, but brittle. Listen now.", "summary": "Step into the 1950s: GI Bill-fueled growth, Levittown suburbs, TV in every living room, and rock n roll teens—alongside redlining, poverty, and early sparks of Civil Rights. Booming, but brittle. Listen now.",
"publishedAt": "2025-09-15T02:28:58.000Z", "publishedAt": "2025-09-15T02:28:58.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/108306385/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-8-15%2Fc354780a-6a27-0df9-03a4-57a85ae25bb9.mp3"
}, },
{ {
"id": "bb4faa6e-384d-4204-b133-438c5a82aefd", "id": "bb4faa6e-384d-4204-b133-438c5a82aefd",
@@ -348,7 +353,8 @@
"title": "E39. US History Understanding This Country | The Korean War: Americas First Test of the Cold War", "title": "E39. US History Understanding This Country | The Korean War: Americas First Test of the Cold War",
"summary": "From the North Korean invasion to MacArthurs Inchon landing, Chinese intervention, and Trumans clash with his general — discover how the Korean War became the blueprint for Cold War conflicts and earned the name “The Forgotten War.”", "summary": "From the North Korean invasion to MacArthurs Inchon landing, Chinese intervention, and Trumans clash with his general — discover how the Korean War became the blueprint for Cold War conflicts and earned the name “The Forgotten War.”",
"publishedAt": "2025-09-07T04:58:14.000Z", "publishedAt": "2025-09-07T04:58:14.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/107954085/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-8-7%2F29c9f21c-f6ea-449f-7e56-2df384af1435.mp3"
}, },
{ {
"id": "2d56a5a0-948d-446f-b05d-f18f4299530f", "id": "2d56a5a0-948d-446f-b05d-f18f4299530f",
@@ -357,7 +363,8 @@
"title": "E38. US History Understanding This Country | Victory to Cold War Tensions", "title": "E38. US History Understanding This Country | Victory to Cold War Tensions",
"summary": "In 1945, victory brought hope and change. From the GI Bill and baby boom to the UN, Marshall Plan, Berlin Airlift, and Trumans Fair Deal — discover how America emerged as a global leader while stepping into the Cold War.", "summary": "In 1945, victory brought hope and change. From the GI Bill and baby boom to the UN, Marshall Plan, Berlin Airlift, and Trumans Fair Deal — discover how America emerged as a global leader while stepping into the Cold War.",
"publishedAt": "2025-08-16T03:52:22.000Z", "publishedAt": "2025-08-16T03:52:22.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/106932614/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-7-16%2F8e76d74b-a4b4-d921-79e0-143c61aa45eb.mp3"
}, },
{ {
"id": "14912927-4ea3-4946-948a-dbeeacbd1535", "id": "14912927-4ea3-4946-948a-dbeeacbd1535",
@@ -366,7 +373,8 @@
"title": "E37. US History Understanding This Country | From D-Day to Nagasaki", "title": "E37. US History Understanding This Country | From D-Day to Nagasaki",
"summary": "Coincidentally, on the anniversary of the bombing of Nagasaki, we retrace Americas path to victory in WWII — from D-Days stormed beaches and the Battle of the Bulge, to the Pacific war, the A-Bomb, and the dawn of a new world order.", "summary": "Coincidentally, on the anniversary of the bombing of Nagasaki, we retrace Americas path to victory in WWII — from D-Days stormed beaches and the Battle of the Bulge, to the Pacific war, the A-Bomb, and the dawn of a new world order.",
"publishedAt": "2025-08-09T04:15:45.000Z", "publishedAt": "2025-08-09T04:15:45.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/106639027/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-7-9%2F62b6e4e3-44d4-7f08-4d80-ae897643a2d3.mp3"
}, },
{ {
"id": "063ab850-90c1-4e42-af44-9a64eaa1bf72", "id": "063ab850-90c1-4e42-af44-9a64eaa1bf72",
@@ -375,7 +383,8 @@
"title": "E36. US History Understanding This Country | The World at War, Again: How the US stepped into WWII", "title": "E36. US History Understanding This Country | The World at War, Again: How the US stepped into WWII",
"summary": "Before Normandy and D-Day, there was fear, fascism, and a world unraveling. In this episode, we trace the rise of totalitarian regimes in Europe and Asia, the blitzkrieg that stunned the West, and the slow but steady shift in American sent…", "summary": "Before Normandy and D-Day, there was fear, fascism, and a world unraveling. In this episode, we trace the rise of totalitarian regimes in Europe and Asia, the blitzkrieg that stunned the West, and the slow but steady shift in American sent…",
"publishedAt": "2025-08-02T03:41:33.000Z", "publishedAt": "2025-08-02T03:41:33.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/106336273/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-7-2%2F404983164-44100-2-bf1a3634fbd3c.m4a"
}, },
{ {
"id": "4d616ef1-46e4-46b5-8fa4-e3e71688d2c0", "id": "4d616ef1-46e4-46b5-8fa4-e3e71688d2c0",
@@ -384,7 +393,8 @@
"title": "E35. US History Understanding This Country | The Great Depression", "title": "E35. US History Understanding This Country | The Great Depression",
"summary": "When the Roaring Twenties crashed into economic ruin, America found itself spiraling into the Great Depression. In this episode, we explore the causes, the fallout, and the ambitious response—from Hoovers failure to FDRs New Deal. Escapi…", "summary": "When the Roaring Twenties crashed into economic ruin, America found itself spiraling into the Great Depression. In this episode, we explore the causes, the fallout, and the ambitious response—from Hoovers failure to FDRs New Deal. Escapi…",
"publishedAt": "2025-07-26T02:32:26.000Z", "publishedAt": "2025-07-26T02:32:26.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/106005813/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-6-26%2F2b97b981-decd-fdd2-1317-ad57c9000792.mp3"
}, },
{ {
"id": "2a5efb30-1749-4bcb-b54b-a83878a6015e", "id": "2a5efb30-1749-4bcb-b54b-a83878a6015e",
@@ -393,7 +403,8 @@
"title": "E34. US History Understanding This Country | The Roaring Twenties", "title": "E34. US History Understanding This Country | The Roaring Twenties",
"summary": "Explore the dazzling highs and hidden lows of 1920s America - from jazz clubs and cultural revolutions to rising nativism, fundamentalism, and an economy teetering on collapse. This episode dives deep into how the decade shaped modern Amer…", "summary": "Explore the dazzling highs and hidden lows of 1920s America - from jazz clubs and cultural revolutions to rising nativism, fundamentalism, and an economy teetering on collapse. This episode dives deep into how the decade shaped modern Amer…",
"publishedAt": "2025-07-19T03:58:56.000Z", "publishedAt": "2025-07-19T03:58:56.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/105702720/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-6-19%2Fd4ce6b57-88f7-d794-d909-3e2cb093bcca.mp3"
}, },
{ {
"id": "d41eeed2-96a0-4afe-a8e4-1cf30dec8988", "id": "d41eeed2-96a0-4afe-a8e4-1cf30dec8988",
@@ -402,7 +413,8 @@
"title": "E33. US History Understanding This Country | The Great War: How World War I Transformed America and the World", "title": "E33. US History Understanding This Country | The Great War: How World War I Transformed America and the World",
"summary": "Explore Americas entry into World War I, from trench warfare and propaganda to Wilsons Fourteen Points and the Treaty of Versailles. Discover how the Great War reshaped the U.S. and set the stage for the Roaring Twenties.", "summary": "Explore Americas entry into World War I, from trench warfare and propaganda to Wilsons Fourteen Points and the Treaty of Versailles. Discover how the Great War reshaped the U.S. and set the stage for the Roaring Twenties.",
"publishedAt": "2025-07-12T04:33:52.000Z", "publishedAt": "2025-07-12T04:33:52.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/105390404/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-6-12%2F18fd798a-1a68-44ec-1076-302d619df5c7.mp3"
}, },
{ {
"id": "1fc5bbbf-e649-41b2-9145-85757cbee0a8", "id": "1fc5bbbf-e649-41b2-9145-85757cbee0a8",
@@ -411,7 +423,8 @@
"title": "E32. US History Understanding This Country | American Muscle and Presidents of Power", "title": "E32. US History Understanding This Country | American Muscle and Presidents of Power",
"summary": "In this episode, we follow Americas bold stride into global influence and domestic reform through the eyes of Theodore Roosevelt, William Howard Taft, and Woodrow Wilson. From the Panama Canal to trust-busting, and from Dollar Diplomacy t…", "summary": "In this episode, we follow Americas bold stride into global influence and domestic reform through the eyes of Theodore Roosevelt, William Howard Taft, and Woodrow Wilson. From the Panama Canal to trust-busting, and from Dollar Diplomacy t…",
"publishedAt": "2025-07-05T03:30:53.000Z", "publishedAt": "2025-07-05T03:30:53.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/105055742/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-6-5%2Fe000ebe6-4a2a-9549-84bb-3dacdc71cf74.mp3"
}, },
{ {
"id": "9d5a87a4-bdb7-474f-a249-a43ac6d477e6", "id": "9d5a87a4-bdb7-474f-a249-a43ac6d477e6",
@@ -420,7 +433,8 @@
"title": "E31. US History Understanding This Country | Expansionism and Imperialism", "title": "E31. US History Understanding This Country | Expansionism and Imperialism",
"summary": "Explore how the United States expanded its reach beyond its borders through imperial ambition, war, and diplomacy. From Hawaii to the Philippines, this episode traces Americas rise as a world power.", "summary": "Explore how the United States expanded its reach beyond its borders through imperial ambition, war, and diplomacy. From Hawaii to the Philippines, this episode traces Americas rise as a world power.",
"publishedAt": "2025-06-28T03:30:57.000Z", "publishedAt": "2025-06-28T03:30:57.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/104747456/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-5-28%2F2b53d550-ce69-539f-03c4-c0b9f6cbf80b.mp3"
}, },
{ {
"id": "6b47cd15-1f10-4175-a8d2-0863777022a7", "id": "6b47cd15-1f10-4175-a8d2-0863777022a7",
@@ -429,7 +443,8 @@
"title": "E30. US History Understanding This Country | Progressivism in America", "title": "E30. US History Understanding This Country | Progressivism in America",
"summary": "Explore the transformative Progressive Era in U.S. history — from trust-busting and muckraking journalism to child labor laws, womens suffrage, and civil rights movements. This episode dives into how reformers, activists, and everyday cit…", "summary": "Explore the transformative Progressive Era in U.S. history — from trust-busting and muckraking journalism to child labor laws, womens suffrage, and civil rights movements. This episode dives into how reformers, activists, and everyday cit…",
"publishedAt": "2025-06-19T03:42:14.000Z", "publishedAt": "2025-06-19T03:42:14.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/104345126/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-5-19%2Fcf16ba9f-60d0-2012-5fc9-4158e495ba5b.mp3"
}, },
{ {
"id": "f73008f3-78d6-4644-975f-f5d00d21f404", "id": "f73008f3-78d6-4644-975f-f5d00d21f404",
@@ -438,7 +453,8 @@
"title": "E29. US History Understanding This Country | Immigration and New Cities", "title": "E29. US History Understanding This Country | Immigration and New Cities",
"summary": "In this episode, we explore the explosive rise of American cities during the late 1800s and early 1900s. From Ellis Island to ethnic neighborhoods, from nativist backlash to reform movements, discover how waves of new immigrants shaped the…", "summary": "In this episode, we explore the explosive rise of American cities during the late 1800s and early 1900s. From Ellis Island to ethnic neighborhoods, from nativist backlash to reform movements, discover how waves of new immigrants shaped the…",
"publishedAt": "2025-06-14T03:58:41.000Z", "publishedAt": "2025-06-14T03:58:41.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/104111816/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-5-14%2F58ab0089-5011-599a-94b6-27349c29025c.mp3"
}, },
{ {
"id": "c1c4778d-2597-4b8a-86e2-dd2e5adfe526", "id": "c1c4778d-2597-4b8a-86e2-dd2e5adfe526",
@@ -447,7 +463,8 @@
"title": "E28. US History Understanding This Country | Second Industrial Revolution and The Age of Capitalism", "title": "E28. US History Understanding This Country | Second Industrial Revolution and The Age of Capitalism",
"summary": "In this episode, we explore the Second Industrial Revolution — a time of booming invention, corporate empires, and factory-floor struggles. From Edisons lightbulb to Fords assembly line, and from the rise of the corporation to the birth…", "summary": "In this episode, we explore the Second Industrial Revolution — a time of booming invention, corporate empires, and factory-floor struggles. From Edisons lightbulb to Fords assembly line, and from the rise of the corporation to the birth…",
"publishedAt": "2025-06-07T03:51:30.000Z", "publishedAt": "2025-06-07T03:51:30.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/103784402/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-5-7%2Feb8aea45-90d7-fbbb-eeb3-f2b1f625ba6a.mp3"
}, },
{ {
"id": "13359365-5be2-4f7c-ae11-94d412be7561", "id": "13359365-5be2-4f7c-ae11-94d412be7561",
@@ -456,7 +473,8 @@
"title": "E27. US History Understanding This Country | Clash of Cultures", "title": "E27. US History Understanding This Country | Clash of Cultures",
"summary": "The American frontier was not an empty land — it was home. In this powerful episode, we uncover the story of the Lakota and other Plains tribes who resisted removal, reservations, and forced assimilation. From Custers Last Stand to the bo…", "summary": "The American frontier was not an empty land — it was home. In this powerful episode, we uncover the story of the Lakota and other Plains tribes who resisted removal, reservations, and forced assimilation. From Custers Last Stand to the bo…",
"publishedAt": "2025-05-31T04:14:46.000Z", "publishedAt": "2025-05-31T04:14:46.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/103457111/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-4-31%2F5d65fcda-a3ad-bfee-b1f3-e87ffcbe4a46.mp3"
}, },
{ {
"id": "f7c449ee-f274-4944-9370-0d0c000a053c", "id": "f7c449ee-f274-4944-9370-0d0c000a053c",
@@ -465,7 +483,8 @@
"title": "E26. US History Understanding This Country | Trains, Bonanzas and Cowboys", "title": "E26. US History Understanding This Country | Trains, Bonanzas and Cowboys",
"summary": "From steam engines to cattle drives, and from homesteads to populist rallies — this episode explores how the American West was won, worked, and mythologized. Discover how the Transcontinental Railroad changed everything, how farmers organi…", "summary": "From steam engines to cattle drives, and from homesteads to populist rallies — this episode explores how the American West was won, worked, and mythologized. Discover how the Transcontinental Railroad changed everything, how farmers organi…",
"publishedAt": "2025-05-24T03:30:17.000Z", "publishedAt": "2025-05-24T03:30:17.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/103126664/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-4-24%2F7bd352a3-c787-98d2-a210-4914742351bc.mp3"
}, },
{ {
"id": "b3edb562-64be-4902-a503-f9a93719caf9", "id": "b3edb562-64be-4902-a503-f9a93719caf9",
@@ -474,7 +493,8 @@
"title": "E25. US History Understanding This Country | Reconstruction: Redefining Freedom", "title": "E25. US History Understanding This Country | Reconstruction: Redefining Freedom",
"summary": "After the Civil War, the United States faced its most difficult question yet: how do you rebuild a country that just tried to destroy itself? In this episode, we explore the highs and heartbreaks of Reconstruction — from the promise of fre…", "summary": "After the Civil War, the United States faced its most difficult question yet: how do you rebuild a country that just tried to destroy itself? In this episode, we explore the highs and heartbreaks of Reconstruction — from the promise of fre…",
"publishedAt": "2025-05-17T04:35:58.000Z", "publishedAt": "2025-05-17T04:35:58.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/102785794/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-4-17%2Ff3a5a2ed-b51a-81bd-3577-85fb982628dd.mp3"
}, },
{ {
"id": "bd1cb52f-29fc-456a-b971-13030def86eb", "id": "bd1cb52f-29fc-456a-b971-13030def86eb",
@@ -483,7 +503,8 @@
"title": "E24. US History Understanding This Country | The Civil War", "title": "E24. US History Understanding This Country | The Civil War",
"summary": "Dive deep into the American Civil War — a conflict that shattered the nation, ended slavery, and changed the course of U.S. history. This episode traces the path from secession to surrender, explores key battles like Gettysburg and Antieta…", "summary": "Dive deep into the American Civil War — a conflict that shattered the nation, ended slavery, and changed the course of U.S. history. This episode traces the path from secession to surrender, explores key battles like Gettysburg and Antieta…",
"publishedAt": "2025-05-10T04:07:23.000Z", "publishedAt": "2025-05-10T04:07:23.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/102452394/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-4-10%2F10329af5-e506-1f22-d2f7-2bc340ab3cc9.mp3"
}, },
{ {
"id": "086d2da0-57b4-4410-8637-f076b19bdaf9", "id": "086d2da0-57b4-4410-8637-f076b19bdaf9",
@@ -492,7 +513,8 @@
"title": "E23. US History Understanding This Country | Prelude to Civil War", "title": "E23. US History Understanding This Country | Prelude to Civil War",
"summary": "In this gripping episode of US History Understanding This Country, we trace the volatile road to the American Civil War—from the Wilmot Proviso to Lincolns First Inaugural Address. Learn how heated debates over slavery, landmark legisla…", "summary": "In this gripping episode of US History Understanding This Country, we trace the volatile road to the American Civil War—from the Wilmot Proviso to Lincolns First Inaugural Address. Learn how heated debates over slavery, landmark legisla…",
"publishedAt": "2025-05-03T03:30:33.000Z", "publishedAt": "2025-05-03T03:30:33.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/102109648/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-4-3%2F22fd23a6-a068-7ab5-3089-6535766eaf26.mp3"
}, },
{ {
"id": "b22a7a44-8028-4c84-9dd6-31e7a62a6def", "id": "b22a7a44-8028-4c84-9dd6-31e7a62a6def",
@@ -501,7 +523,8 @@
"title": "E22. US History Understanding This Country | The Age of Reform", "title": "E22. US History Understanding This Country | The Age of Reform",
"summary": "In this episode of US History Understanding This Country, explore the powerful movements that reshaped America in the mid-1800s. From immigration waves and city life challenges to the Second Great Awakening, the Temperance Movement, pris…", "summary": "In this episode of US History Understanding This Country, explore the powerful movements that reshaped America in the mid-1800s. From immigration waves and city life challenges to the Second Great Awakening, the Temperance Movement, pris…",
"publishedAt": "2025-04-26T03:38:18.000Z", "publishedAt": "2025-04-26T03:38:18.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/101800723/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-3-26%2F02c65821-3a9f-b57d-5b28-897ea433ee8c.mp3"
}, },
{ {
"id": "035b8239-d954-42a8-a1a5-d4fce21ff15a", "id": "035b8239-d954-42a8-a1a5-d4fce21ff15a",
@@ -510,7 +533,8 @@
"title": "E21. US History Understanding This Country | The Lone Star and the Borderlands", "title": "E21. US History Understanding This Country | The Lone Star and the Borderlands",
"summary": "In this episode of US History Understanding This Country, we explore how Texas went from Mexican territory to independent republic—and how that sparked the Mexican-American War. Learn about the Alamo, the Battle of San Jacinto, the Treat…", "summary": "In this episode of US History Understanding This Country, we explore how Texas went from Mexican territory to independent republic—and how that sparked the Mexican-American War. Learn about the Alamo, the Battle of San Jacinto, the Treat…",
"publishedAt": "2025-04-19T03:26:55.000Z", "publishedAt": "2025-04-19T03:26:55.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/101486753/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-3-19%2F490ffdc1-c6f6-b467-1687-4eb3143ee377.mp3"
}, },
{ {
"id": "92f62156-44ed-4757-b46b-7b8d3b0deb00", "id": "92f62156-44ed-4757-b46b-7b8d3b0deb00",
@@ -519,7 +543,8 @@
"title": "E20. US History Understanding This Country | The Oregon Trail", "title": "E20. US History Understanding This Country | The Oregon Trail",
"summary": "In this immersive episode of US History Understanding This Country, we journey along the iconic Oregon Trail — the 2,000-mile route that carried Americas pioneer spirit westward. From river crossings and food shortages to the resilience…", "summary": "In this immersive episode of US History Understanding This Country, we journey along the iconic Oregon Trail — the 2,000-mile route that carried Americas pioneer spirit westward. From river crossings and food shortages to the resilience…",
"publishedAt": "2025-04-12T04:39:58.000Z", "publishedAt": "2025-04-12T04:39:58.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/101188164/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-3-12%2Fbcfaf501-9f8b-1ad7-f7ea-fda20e0604b3.mp3"
}, },
{ {
"id": "3fef0da4-41a1-4f69-a444-30a846ba6817", "id": "3fef0da4-41a1-4f69-a444-30a846ba6817",
@@ -528,7 +553,8 @@
"title": "E19. US History Understanding This Country | The Wild West Journeys", "title": "E19. US History Understanding This Country | The Wild West Journeys",
"summary": "In this gripping episode of US History Understanding This Country, we journey deep into the legendary Oregon Trail — the 2,000-mile path that turned ordinary families into pioneers and a young nation into a continental power. Discover wh…", "summary": "In this gripping episode of US History Understanding This Country, we journey deep into the legendary Oregon Trail — the 2,000-mile path that turned ordinary families into pioneers and a young nation into a continental power. Discover wh…",
"publishedAt": "2025-04-04T23:00:00.000Z", "publishedAt": "2025-04-04T23:00:00.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/100819921/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-3-4%2F5d62ff2f-45f0-e0f9-da75-c49272b2cbdb.mp3"
}, },
{ {
"id": "564c1115-f9ac-4c18-8e6f-2e805db638e2", "id": "564c1115-f9ac-4c18-8e6f-2e805db638e2",
@@ -537,7 +563,8 @@
"title": "E18. US History Understanding This Country | From Corrupt Bargains to the Trail of Tears", "title": "E18. US History Understanding This Country | From Corrupt Bargains to the Trail of Tears",
"summary": "In this powerful episode of US History Understanding This Country, host Santhosh Janardhanan unpacks the pivotal elections of 1824, 1828, and 1832—revealing how political rivalries, populist movements, and bitter controversies shaped the…", "summary": "In this powerful episode of US History Understanding This Country, host Santhosh Janardhanan unpacks the pivotal elections of 1824, 1828, and 1832—revealing how political rivalries, populist movements, and bitter controversies shaped the…",
"publishedAt": "2025-03-29T04:10:39.000Z", "publishedAt": "2025-03-29T04:10:39.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/100536780/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-2-29%2Fafe493af-8b1d-edf5-5375-00ef312acf0c.mp3"
}, },
{ {
"id": "3fcbc28c-728f-42cf-83cd-812ba49db367", "id": "3fcbc28c-728f-42cf-83cd-812ba49db367",
@@ -546,7 +573,8 @@
"title": "E17. US History Understanding This Country | The Era of Good Feelings? Nationalism, Industry & Division in Early America", "title": "E17. US History Understanding This Country | The Era of Good Feelings? Nationalism, Industry & Division in Early America",
"summary": "Was the “Era of Good Feelings” truly a time of peace and progress—or a calm before the storm? In Episode 17 of US History Understanding This Country, we explore how the post-War of 1812 boom brought nationalism, innovation, and expansion…", "summary": "Was the “Era of Good Feelings” truly a time of peace and progress—or a calm before the storm? In Episode 17 of US History Understanding This Country, we explore how the post-War of 1812 boom brought nationalism, innovation, and expansion…",
"publishedAt": "2025-03-22T01:00:00.000Z", "publishedAt": "2025-03-22T01:00:00.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/100165431/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-2-21%2F55bc62cd-e2d6-94cc-2d33-b3f6f0f012c4.mp3"
}, },
{ {
"id": "dbf4251d-3b67-4e98-be85-d4888aac4357", "id": "dbf4251d-3b67-4e98-be85-d4888aac4357",
@@ -555,7 +583,8 @@
"title": "E16. US History Understanding This Country | The War of 1812 and Birth of the Star Spangled Banner", "title": "E16. US History Understanding This Country | The War of 1812 and Birth of the Star Spangled Banner",
"summary": "The War of 1812 shaped Americas destiny—Tecumsehs confederation, the Battle of Tippecanoe, the burning of Washington D.C., the Star-Spangled Banner, and Andrew Jacksons victory at New Orleans. Discover how this war redefined U.S. indepe…", "summary": "The War of 1812 shaped Americas destiny—Tecumsehs confederation, the Battle of Tippecanoe, the burning of Washington D.C., the Star-Spangled Banner, and Andrew Jacksons victory at New Orleans. Discover how this war redefined U.S. indepe…",
"publishedAt": "2025-03-15T01:00:00.000Z", "publishedAt": "2025-03-15T01:00:00.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/99676277/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-2-11%2F396348523-44100-2-17d81a634ca2e.m4a"
}, },
{ {
"id": "87f6b19f-b74a-4345-929e-084dec7236b5", "id": "87f6b19f-b74a-4345-929e-084dec7236b5",
@@ -564,7 +593,8 @@
"title": "E15. US History - Understanding this Country | Expansion, Power, and Contradictions", "title": "E15. US History - Understanding this Country | Expansion, Power, and Contradictions",
"summary": "Thomas Jeffersons presidency reshaped America—doubling its size with the Louisiana Purchase, battling the Supreme Court, and enforcing the disastrous Embargo Act. Explore his legacy in this episode!", "summary": "Thomas Jeffersons presidency reshaped America—doubling its size with the Louisiana Purchase, battling the Supreme Court, and enforcing the disastrous Embargo Act. Explore his legacy in this episode!",
"publishedAt": "2025-03-08T05:43:34.000Z", "publishedAt": "2025-03-08T05:43:34.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/99544328/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-2-8%2F396185365-44100-2-2dc56d748f8c2.m4a"
}, },
{ {
"id": "0bb43b9e-c1bc-40be-b642-5b8c0a7977a1", "id": "0bb43b9e-c1bc-40be-b642-5b8c0a7977a1",
@@ -573,7 +603,8 @@
"title": "E14. US History - Understanding this Country | Presidents setting precedents", "title": "E14. US History - Understanding this Country | Presidents setting precedents",
"summary": "Dive into the transformative era of the first presidents as they laid the groundwork for the United States by setting precedents in early America. This episode traces George Washingtons historic election, the formation of his cabinet, and…", "summary": "Dive into the transformative era of the first presidents as they laid the groundwork for the United States by setting precedents in early America. This episode traces George Washingtons historic election, the formation of his cabinet, and…",
"publishedAt": "2025-03-01T04:33:33.000Z", "publishedAt": "2025-03-01T04:33:33.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/99202920/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-2-1%2F54f89a3e-6cb7-9d3d-ece0-d831d3739ab3.mp3"
}, },
{ {
"id": "344e00d3-7a6c-4941-81d2-cb3c60f567cb", "id": "344e00d3-7a6c-4941-81d2-cb3c60f567cb",
@@ -582,7 +613,8 @@
"title": "E13. US History - Understanding this Country | Inside the U.S. Constitution", "title": "E13. US History - Understanding this Country | Inside the U.S. Constitution",
"summary": "The U.S. Constitution was written—but could it survive the fight for approval? In this episode, we dive into the ratification debates, the clash between Federalists and Anti-Federalists, and the political deals that saved the Constitution.…", "summary": "The U.S. Constitution was written—but could it survive the fight for approval? In this episode, we dive into the ratification debates, the clash between Federalists and Anti-Federalists, and the political deals that saved the Constitution.…",
"publishedAt": "2025-02-23T03:35:43.000Z", "publishedAt": "2025-02-23T03:35:43.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/98885311/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-1-23%2F395361579-44100-2-a3f5b1899c71.m4a"
}, },
{ {
"id": "2ffd8004-b419-4260-80de-e3e04518f70d", "id": "2ffd8004-b419-4260-80de-e3e04518f70d",
@@ -591,7 +623,8 @@
"title": "E12. US History - Understanding this Country | Finding the Balance", "title": "E12. US History - Understanding this Country | Finding the Balance",
"summary": "The U.S. Constitution wasnt built overnight—it was shaped by intense debates and hard-fought compromises. In this episode, we dive into The Great Compromise, The Three-Fifths Compromise, and how these decisions created the foundation for…", "summary": "The U.S. Constitution wasnt built overnight—it was shaped by intense debates and hard-fought compromises. In this episode, we dive into The Great Compromise, The Three-Fifths Compromise, and how these decisions created the foundation for…",
"publishedAt": "2025-02-16T04:57:29.000Z", "publishedAt": "2025-02-16T04:57:29.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/98561452/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-1-16%2F394955738-44100-2-7d2c7d88c368.m4a"
}, },
{ {
"id": "ba1a2bcc-8ba8-40f6-afef-5528a7dae897", "id": "ba1a2bcc-8ba8-40f6-afef-5528a7dae897",
@@ -600,7 +633,8 @@
"title": "E11. US History - Understanding this Country | Building a nation from the scratch", "title": "E11. US History - Understanding this Country | Building a nation from the scratch",
"summary": "How did the U.S. go from revolution to a functioning government? Of course, it was not a cake walk. In this episode, we break down the period of Article of Confederation - the first government in the U.S, creation of the Constitution, the…", "summary": "How did the U.S. go from revolution to a functioning government? Of course, it was not a cake walk. In this episode, we break down the period of Article of Confederation - the first government in the U.S, creation of the Constitution, the…",
"publishedAt": "2025-02-09T04:42:25.000Z", "publishedAt": "2025-02-09T04:42:25.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/98227236/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-1-9%2F394539098-44100-2-212c5fa4d510a.m4a"
}, },
{ {
"id": "0c25ee7a-bc69-4791-995a-07cc9456f980", "id": "0c25ee7a-bc69-4791-995a-07cc9456f980",
@@ -609,7 +643,8 @@
"title": "E10. US History - Understanding this Country | The Revolutions Final Stand", "title": "E10. US History - Understanding this Country | The Revolutions Final Stand",
"summary": "The American Revolution wasnt just won on battlefields—it was won through resilience, strategy, and unexpected allies. In this episode, we cover the final battles, the global impact of the war, and the unsung heroes who made independence…", "summary": "The American Revolution wasnt just won on battlefields—it was won through resilience, strategy, and unexpected allies. In this episode, we cover the final battles, the global impact of the war, and the unsung heroes who made independence…",
"publishedAt": "2025-02-01T02:00:00.000Z", "publishedAt": "2025-02-01T02:00:00.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/97766177/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-0-30%2F393965209-44100-2-301b990bd3a5a.m4a"
}, },
{ {
"id": "047a6592-70d3-4ff9-9f2f-f6302c4091f0", "id": "047a6592-70d3-4ff9-9f2f-f6302c4091f0",
@@ -618,7 +653,8 @@
"title": "E9. US History - Understanding this Country | From Declaration to Victory", "title": "E9. US History - Understanding this Country | From Declaration to Victory",
"summary": "Explore the gritty journey from the Declaration of Independence to the early milestones of the American Revolution. In this episode, we cover the Battle of Long Island, Washingtons daring Delaware crossing, the turning point at Saratoga,…", "summary": "Explore the gritty journey from the Declaration of Independence to the early milestones of the American Revolution. In this episode, we cover the Battle of Long Island, Washingtons daring Delaware crossing, the turning point at Saratoga,…",
"publishedAt": "2025-01-26T04:01:00.000Z", "publishedAt": "2025-01-26T04:01:00.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/97569318/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-0-26%2F393720338-44100-2-eeeb29f35922e.m4a"
}, },
{ {
"id": "c7484015-9116-4cd2-ae10-95e10b25cfe2", "id": "c7484015-9116-4cd2-ae10-95e10b25cfe2",
@@ -627,7 +663,8 @@
"title": "E8. US History - Understanding this Country | The Road to Independence", "title": "E8. US History - Understanding this Country | The Road to Independence",
"summary": "Join us in Episode 8 of Irregular Mind as we explore the pivotal moments that shaped America's fight for freedom. From the Second Continental Congress to the bold stand at Bunker Hill, the Olive Branch Petition, and Washingtons victory in…", "summary": "Join us in Episode 8 of Irregular Mind as we explore the pivotal moments that shaped America's fight for freedom. From the Second Continental Congress to the bold stand at Bunker Hill, the Olive Branch Petition, and Washingtons victory in…",
"publishedAt": "2025-01-18T05:08:28.000Z", "publishedAt": "2025-01-18T05:08:28.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/97214840/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-0-18%2Fdbb916af-1d80-4a9c-940a-977cea9a00fd.m4a"
}, },
{ {
"id": "3f3949a2-032d-41ed-bb40-29d5d39ecd63", "id": "3f3949a2-032d-41ed-bb40-29d5d39ecd63",
@@ -636,7 +673,8 @@
"title": "E7. US History - Understanding this Country | The British Are Coming", "title": "E7. US History - Understanding this Country | The British Are Coming",
"summary": "Dive into the electrifying moments that sparked the American Revolution in this episode of Irregular Mind. From the bold actions of the Sons of Liberty and Patrick Henrys fiery “Give me liberty or give me death” speech to the first shots…", "summary": "Dive into the electrifying moments that sparked the American Revolution in this episode of Irregular Mind. From the bold actions of the Sons of Liberty and Patrick Henrys fiery “Give me liberty or give me death” speech to the first shots…",
"publishedAt": "2025-01-10T04:14:48.000Z", "publishedAt": "2025-01-10T04:14:48.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/96847344/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-0-10%2F5324f9ad-08ae-e065-e84f-ce1716eca1c7.m4a"
}, },
{ {
"id": "e415d267-4104-4f40-a6d8-364cd6c36ccb", "id": "e415d267-4104-4f40-a6d8-364cd6c36ccb",
@@ -645,7 +683,8 @@
"title": "E6. US History - Understanding this Country | The Spark of Revolution", "title": "E6. US History - Understanding this Country | The Spark of Revolution",
"summary": "In this episode of Irregular Mind, we trace the growing tensions that lit the fuse of the American Revolution. Explore the cries of “No Taxation Without Representation”, the defiance of the Stamp Act, and the fiery rhetoric of Patrick Henr…", "summary": "In this episode of Irregular Mind, we trace the growing tensions that lit the fuse of the American Revolution. Explore the cries of “No Taxation Without Representation”, the defiance of the Stamp Act, and the fiery rhetoric of Patrick Henr…",
"publishedAt": "2025-01-04T14:12:00.000Z", "publishedAt": "2025-01-04T14:12:00.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/96502701/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2025-0-2%2F67a41fef-5353-4525-b259-1dc76c111c3e.m4a"
}, },
{ {
"id": "82811636-1652-4d69-8c80-62a3021ecc18", "id": "82811636-1652-4d69-8c80-62a3021ecc18",
@@ -654,7 +693,8 @@
"title": "E5. US History - Understanding this Country | Colonial America: The Spark Before the Revolution", "title": "E5. US History - Understanding this Country | Colonial America: The Spark Before the Revolution",
"summary": "Dive into the pivotal moments that set the stage for the American Revolution in this episode of Irregular Mind. Explore the impact of the English Bill of Rights, the Great Awakening, and the Intolerable Acts as tensions between Britain and…", "summary": "Dive into the pivotal moments that set the stage for the American Revolution in this episode of Irregular Mind. Explore the impact of the English Bill of Rights, the Great Awakening, and the Intolerable Acts as tensions between Britain and…",
"publishedAt": "2024-12-28T03:46:34.000Z", "publishedAt": "2024-12-28T03:46:34.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/96337408/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2024-11-28%2F392191473-44100-2-151ecd13be558.m4a"
}, },
{ {
"id": "edae75c4-3a65-4771-8445-ae4a76e9c6c9", "id": "edae75c4-3a65-4771-8445-ae4a76e9c6c9",
@@ -663,7 +703,8 @@
"title": "E4. US History - Understanding this Country | Colonial America: Roots of a New Nation", "title": "E4. US History - Understanding this Country | Colonial America: Roots of a New Nation",
"summary": "Explore the birth of Colonial America in this episode of Irregular Mind. Discover the rise of Virginia, the establishment of the Thirteen Colonies, and how regional differences shaped the early American identity. From Jamestowns struggles…", "summary": "Explore the birth of Colonial America in this episode of Irregular Mind. Discover the rise of Virginia, the establishment of the Thirteen Colonies, and how regional differences shaped the early American identity. From Jamestowns struggles…",
"publishedAt": "2024-12-20T13:30:00.000Z", "publishedAt": "2024-12-20T13:30:00.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/95927257/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2024-11-17%2F391677742-44100-2-ea0a557d872b7.m4a"
}, },
{ {
"id": "87c0d58b-ac9f-4017-a269-c7f16ff67587", "id": "87c0d58b-ac9f-4017-a269-c7f16ff67587",
@@ -672,7 +713,8 @@
"title": "E3. US History - Understanding this Country | Empires, Exploration, and the Birth of Colonies", "title": "E3. US History - Understanding this Country | Empires, Exploration, and the Birth of Colonies",
"summary": "Discover the dramatic shifts in power as the Spanish Armada falls, the rise of tobacco in Virginia, and the relentless search for the Northwest Passage. Explore the early European settlements, trade networks, and cultural exchanges that sh…", "summary": "Discover the dramatic shifts in power as the Spanish Armada falls, the rise of tobacco in Virginia, and the relentless search for the Northwest Passage. Explore the early European settlements, trade networks, and cultural exchanges that sh…",
"publishedAt": "2024-12-17T04:34:51.000Z", "publishedAt": "2024-12-17T04:34:51.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/95721830/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2024-11-12%2F391421749-44100-2-528bde0d8cb69.m4a"
}, },
{ {
"id": "d8f3684a-1fab-4ff1-a638-d48564c870a5", "id": "d8f3684a-1fab-4ff1-a638-d48564c870a5",
@@ -681,7 +723,8 @@
"title": "E2. US History - Understanding this Country | 1492 and Beyond: The Atlantic World Unveiled", "title": "E2. US History - Understanding this Country | 1492 and Beyond: The Atlantic World Unveiled",
"summary": "Uncover the pivotal moments that reshaped history in Episode Two of Irregular Mind's U.S. history series, \"Understanding This Country.\" From Columbus' daring voyage and the race for riches in 15th-century Europe to the clash of cultures, t…", "summary": "Uncover the pivotal moments that reshaped history in Episode Two of Irregular Mind's U.S. history series, \"Understanding This Country.\" From Columbus' daring voyage and the race for riches in 15th-century Europe to the clash of cultures, t…",
"publishedAt": "2024-12-09T05:11:09.000Z", "publishedAt": "2024-12-09T05:11:09.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/95563461/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2024-11-9%2F35bb2106-d023-5bda-ac51-291f03816da4.mp3"
}, },
{ {
"id": "7f5c488b-3a7b-4d62-847c-fd5a807577a9", "id": "7f5c488b-3a7b-4d62-847c-fd5a807577a9",
@@ -690,7 +733,8 @@
"title": "E1. US History - Understanding this Country", "title": "E1. US History - Understanding this Country",
"summary": "I came to the United States chasing opportunities, but I quickly realized that this land is more than just skyscrapers and Hollywood. The history of this place is layered, complicated, and, honestly, sometimes stranger than fiction. And th…", "summary": "I came to the United States chasing opportunities, but I quickly realized that this land is more than just skyscrapers and Hollywood. The history of this place is layered, complicated, and, honestly, sometimes stranger than fiction. And th…",
"publishedAt": "2024-12-02T05:02:25.000Z", "publishedAt": "2024-12-02T05:02:25.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/95226944/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2F2024-11-9%2F58a5abaf-88d1-bf43-c09b-63fff3fbeefa.mp3"
}, },
{ {
"id": "f25afab7-54b8-4a61-bc0d-143986c7d475", "id": "f25afab7-54b8-4a61-bc0d-143986c7d475",
@@ -699,7 +743,8 @@
"title": "One Person - One Relation", "title": "One Person - One Relation",
"summary": "A common issue faced by most of us - relationships. Just a wakeup call on it.", "summary": "A common issue faced by most of us - relationships. Just a wakeup call on it.",
"publishedAt": "2022-03-12T19:43:48.000Z", "publishedAt": "2022-03-12T19:43:48.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded400/7490178/7490178-1644680237670-d15b3f1acda1b.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded400/7490178/7490178-1644680237670-d15b3f1acda1b.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/48954123/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fproduction%2Fexports%2F2d3db148%2F48954123%2F7881df2d251946b11f07aa114a313114.m4a"
}, },
{ {
"id": "95b07a0e-6f13-4822-9d4f-2fa74dd4cff9", "id": "95b07a0e-6f13-4822-9d4f-2fa74dd4cff9",
@@ -708,7 +753,8 @@
"title": "Dharmaraja.- Conclusion", "title": "Dharmaraja.- Conclusion",
"summary": "Conclusion and Aftermath of Yogi's plot.", "summary": "Conclusion and Aftermath of Yogi's plot.",
"publishedAt": "2022-02-05T19:28:44.000Z", "publishedAt": "2022-02-05T19:28:44.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded_episode/7490178/7490178-1644089311927-91c36cb8383e5.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded_episode/7490178/7490178-1644089311927-91c36cb8383e5.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/47228562/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fproduction%2Fexports%2F2d3db148%2F47228562%2Fe1c5f1c941e23d8716f0531afc530901.m4a"
}, },
{ {
"id": "e72fb42d-1f10-4fe6-842c-811e260a54e7", "id": "e72fb42d-1f10-4fe6-842c-811e260a54e7",
@@ -717,7 +763,8 @@
"title": "Dharmaraja - Episode 28", "title": "Dharmaraja - Episode 28",
"summary": "The final showdown. Major players face off. And Yogi does the undoable.", "summary": "The final showdown. Major players face off. And Yogi does the undoable.",
"publishedAt": "2022-02-05T19:02:09.000Z", "publishedAt": "2022-02-05T19:02:09.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded_episode/7490178/7490178-1644087715926-5f708b8948277.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded_episode/7490178/7490178-1644087715926-5f708b8948277.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/47227651/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fproduction%2Fexports%2F2d3db148%2F47227651%2F3edd96c916d24c499670aa71135e7e18.m4a"
}, },
{ {
"id": "e1c0d4dc-35e7-4cb7-b022-0bebef8c3ed1", "id": "e1c0d4dc-35e7-4cb7-b022-0bebef8c3ed1",
@@ -726,7 +773,8 @@
"title": "Dharmaraja - Episode 27", "title": "Dharmaraja - Episode 27",
"summary": "Yogi is shocked with a surprise news. Relationship between brothers crack. Can Yogi see what kind of mess he is into?", "summary": "Yogi is shocked with a surprise news. Relationship between brothers crack. Can Yogi see what kind of mess he is into?",
"publishedAt": "2022-02-05T17:52:55.000Z", "publishedAt": "2022-02-05T17:52:55.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded_episode/7490178/7490178-1644083569325-d4aaaf3e6d72.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded_episode/7490178/7490178-1644083569325-d4aaaf3e6d72.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/47224768/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fproduction%2Fexports%2F2d3db148%2F47224768%2F8edbd4996efddbd8e7e058889e1ff20b.m4a"
}, },
{ {
"id": "677892be-4457-49cb-a2b1-b7a696ea7275", "id": "677892be-4457-49cb-a2b1-b7a696ea7275",
@@ -735,7 +783,8 @@
"title": "Dharmaraja - Episode 26", "title": "Dharmaraja - Episode 26",
"summary": "Helplessness grasps Chandrakkaran. Thripurasundarikunjamma sees visions...", "summary": "Helplessness grasps Chandrakkaran. Thripurasundarikunjamma sees visions...",
"publishedAt": "2022-02-04T21:12:06.000Z", "publishedAt": "2022-02-04T21:12:06.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded_episode/7490178/7490178-1644083230812-bbcda04085e26.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded_episode/7490178/7490178-1644083230812-bbcda04085e26.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/47186638/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fproduction%2Fexports%2F2d3db148%2F47186638%2Fdcbf3696cbde84e8db2d1bde36aebc7b.m4a"
}, },
{ {
"id": "8a09387c-bee8-46b9-97f1-828e28dfa09a", "id": "8a09387c-bee8-46b9-97f1-828e28dfa09a",
@@ -744,7 +793,8 @@
"title": "Dharmaraja - Episode 25", "title": "Dharmaraja - Episode 25",
"summary": "Conflict comes to day light. Kesavan Kunju escapes. Padathalavan is all out beast mode after knowing what happened to Kuppassar.", "summary": "Conflict comes to day light. Kesavan Kunju escapes. Padathalavan is all out beast mode after knowing what happened to Kuppassar.",
"publishedAt": "2022-02-03T21:23:34.000Z", "publishedAt": "2022-02-03T21:23:34.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded_episode/7490178/7490178-1643923406877-eef4729d8dab5.jpg" "thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded_episode/7490178/7490178-1643923406877-eef4729d8dab5.jpg",
"audioUrl": "https://anchor.fm/s/2d3db148/podcast/play/47135056/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fproduction%2Fexports%2F2d3db148%2F47135056%2F90996aae5ca4bb6cb4103e2adc0c9c5e.m4a"
} }
], ],
"wordpress": { "wordpress": {

View File

@@ -0,0 +1,3 @@
{
"2dad79d8-ed54-42a8-91a1-4c12a22e3070": "https://open.spotify.com/episode/EPISODE_ID"
}

View File

@@ -9,6 +9,7 @@
"fetch-content": "tsx scripts/fetch-content.ts", "fetch-content": "tsx scripts/fetch-content.ts",
"cache:clear": "tsx scripts/cache-clear.ts", "cache:clear": "tsx scripts/cache-clear.ts",
"verify:blog": "npm run build && tsx scripts/verify-blog-build.ts", "verify:blog": "npm run build && tsx scripts/verify-blog-build.ts",
"verify:umami": "npm run build && tsx scripts/verify-umami-in-dist.ts",
"typecheck": "astro check", "typecheck": "astro check",
"format": "prettier -w .", "format": "prettier -w .",
"format:check": "prettier -c .", "format:check": "prettier -c .",

BIN
site/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -9,6 +9,85 @@
--accent: #ffcd4a; --accent: #ffcd4a;
--accent2: #5ee4ff; --accent2: #5ee4ff;
--focus: rgba(94, 228, 255, 0.95); --focus: rgba(94, 228, 255, 0.95);
--stroke-weak: rgba(255, 255, 255, 0.08);
--stroke-mid: rgba(255, 255, 255, 0.12);
--stroke-strong: rgba(255, 255, 255, 0.18);
--layer-1: rgba(255, 255, 255, 0.04);
--layer-2: rgba(255, 255, 255, 0.06);
--layer-3: rgba(255, 255, 255, 0.08);
--surface-0: rgba(10, 14, 28, 0.7);
--surface-1: rgba(10, 14, 28, 0.92);
--glow-a: rgba(94, 228, 255, 0.22);
--glow-b: rgba(255, 205, 74, 0.18);
--glow-c: rgba(140, 88, 255, 0.14);
--theme-notch-top: 84px;
}
html {
color-scheme: dark;
}
html[data-theme="light"] {
color-scheme: light;
--bg0: #f7f9fc;
--bg1: #e9eef6;
--fg: #0b1224;
--muted: rgba(11, 18, 36, 0.7);
--card: rgba(0, 0, 0, 0.03);
--card2: rgba(0, 0, 0, 0.05);
--stroke: rgba(0, 0, 0, 0.14);
--accent: #b45309;
--accent2: #0ea5b7;
--focus: rgba(14, 165, 183, 0.85);
--stroke-weak: rgba(0, 0, 0, 0.08);
--stroke-mid: rgba(0, 0, 0, 0.12);
--stroke-strong: rgba(0, 0, 0, 0.18);
--layer-1: rgba(0, 0, 0, 0.03);
--layer-2: rgba(0, 0, 0, 0.045);
--layer-3: rgba(0, 0, 0, 0.06);
--surface-0: rgba(255, 255, 255, 0.78);
--surface-1: rgba(255, 255, 255, 0.92);
--glow-a: rgba(14, 165, 183, 0.18);
--glow-b: rgba(180, 83, 9, 0.16);
--glow-c: rgba(37, 99, 235, 0.12);
}
html[data-theme="high-contrast"] {
color-scheme: dark;
--bg0: #000000;
--bg1: #000000;
--fg: #ffffff;
--muted: rgba(255, 255, 255, 0.92);
--card: rgba(0, 0, 0, 0.85);
--card2: rgba(0, 0, 0, 0.92);
--stroke: rgba(255, 255, 255, 0.85);
--accent: #ffcd4a;
--accent2: #5ee4ff;
--focus: rgba(255, 255, 255, 0.95);
--stroke-weak: rgba(255, 255, 255, 0.55);
--stroke-mid: rgba(255, 255, 255, 0.75);
--stroke-strong: rgba(255, 255, 255, 0.9);
--layer-1: rgba(255, 255, 255, 0.08);
--layer-2: rgba(255, 255, 255, 0.12);
--layer-3: rgba(255, 255, 255, 0.16);
--surface-0: rgba(0, 0, 0, 0.9);
--surface-1: rgba(0, 0, 0, 0.96);
--glow-a: transparent;
--glow-b: transparent;
--glow-c: transparent;
} }
* { * {
@@ -48,9 +127,9 @@ body::before {
z-index: -1; z-index: -1;
pointer-events: none; pointer-events: none;
background: background:
radial-gradient(1200px 800px at 10% 10%, rgba(94, 228, 255, 0.22), transparent 60%), radial-gradient(1200px 800px at 10% 10%, var(--glow-a), transparent 60%),
radial-gradient(1100px 800px at 90% 20%, rgba(255, 205, 74, 0.18), transparent 58%), radial-gradient(1100px 800px at 90% 20%, var(--glow-b), transparent 58%),
radial-gradient(1200px 900px at 30% 90%, rgba(140, 88, 255, 0.14), transparent 62%); radial-gradient(1200px 900px at 30% 90%, var(--glow-c), transparent 62%);
} }
a { a {
@@ -75,8 +154,8 @@ textarea:focus-visible {
z-index: 999; z-index: 999;
padding: 10px 12px; padding: 10px 12px;
border-radius: 999px; border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.18); border: 1px solid var(--stroke-strong);
background: rgba(10, 14, 28, 0.92); background: var(--surface-1);
color: var(--fg); color: var(--fg);
font-weight: 800; font-weight: 800;
transform: translateY(-220%); transform: translateY(-220%);
@@ -98,8 +177,8 @@ textarea:focus-visible {
top: 0; top: 0;
z-index: 10; z-index: 10;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
background: rgba(10, 14, 28, 0.7); background: var(--surface-0);
border-bottom: 1px solid rgba(255, 255, 255, 0.08); border-bottom: 1px solid var(--stroke-weak);
padding: 14px 24px; padding: 14px 24px;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -135,8 +214,8 @@ textarea:focus-visible {
width: 44px; width: 44px;
height: 44px; height: 44px;
border-radius: 999px; border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.14); border: 1px solid var(--stroke-mid);
background: rgba(255, 255, 255, 0.04); background: var(--layer-1);
color: var(--fg); color: var(--fg);
} }
@@ -155,12 +234,12 @@ textarea:focus-visible {
right: 0; right: 0;
height: 2px; height: 2px;
border-radius: 999px; border-radius: 999px;
background: rgba(242, 244, 255, 0.92); background: color-mix(in srgb, var(--fg) 92%, transparent);
} }
.nav-toggle-icon::before { .nav-toggle-icon::before {
top: 0; top: 0;
box-shadow: 0 5px 0 rgba(242, 244, 255, 0.92); box-shadow: 0 5px 0 color-mix(in srgb, var(--fg) 92%, transparent);
} }
.nav-toggle-icon::after { .nav-toggle-icon::after {
@@ -186,11 +265,11 @@ textarea:focus-visible {
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
border-radius: 16px; border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.14); border: 1px solid var(--stroke-mid);
background: rgba(10, 14, 28, 0.92); background: var(--surface-1);
box-shadow: box-shadow:
0 18px 60px rgba(0, 0, 0, 0.55), 0 18px 60px rgba(0, 0, 0, 0.55),
0 0 0 1px rgba(255, 255, 255, 0.05) inset; 0 0 0 1px color-mix(in srgb, var(--stroke-weak) 60%, transparent) inset;
transform-origin: top right; transform-origin: top right;
transition: transition:
opacity 160ms ease, opacity 160ms ease,
@@ -219,11 +298,226 @@ textarea:focus-visible {
.nav a { .nav a {
padding: 12px 12px; padding: 12px 12px;
border-radius: 12px; border-radius: 12px;
background: rgba(255, 255, 255, 0.04); background: var(--layer-1);
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid var(--stroke-weak);
} }
} }
.theme-notch {
position: fixed;
top: var(--theme-notch-top);
right: max(8px, env(safe-area-inset-right));
z-index: 12;
display: flex;
align-items: center;
gap: 10px;
pointer-events: none;
}
.theme-notch > * {
pointer-events: auto;
}
.theme-notch-handle {
height: 46px;
width: 60px;
border-radius: 16px 0 0 16px;
border: 1px solid var(--stroke-mid);
border-right: 0;
background: linear-gradient(180deg, var(--layer-3), var(--layer-1));
color: var(--fg);
cursor: pointer;
backdrop-filter: blur(10px);
box-shadow:
0 14px 42px rgba(0, 0, 0, 0.28),
0 0 0 1px color-mix(in srgb, var(--stroke-weak) 60%, transparent) inset;
transition:
transform 160ms ease,
box-shadow 160ms ease,
background 160ms ease;
}
.theme-notch-glyph {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
line-height: 1;
}
.theme-notch-glyph svg {
width: 18px;
height: 18px;
}
.theme-notch-panel {
position: absolute;
right: 60px;
top: 0;
display: grid;
gap: 6px;
padding: 12px;
border-radius: 16px;
border: 1px solid var(--stroke-mid);
background: var(--surface-1);
box-shadow:
0 18px 60px rgba(0, 0, 0, 0.32),
0 0 0 1px color-mix(in srgb, var(--stroke-weak) 60%, transparent) inset;
opacity: 0;
transform: translateX(10px) scale(0.98);
visibility: hidden;
pointer-events: none;
transition:
opacity 160ms ease,
transform 160ms ease,
visibility 0s linear 160ms;
}
.theme-notch:hover .theme-notch-panel,
.theme-notch[data-open="true"] .theme-notch-panel,
.theme-notch:focus-within .theme-notch-panel {
opacity: 1;
transform: translateX(0) scale(1);
visibility: visible;
pointer-events: auto;
transition:
opacity 160ms ease,
transform 160ms ease,
visibility 0s;
}
.theme-notch:hover .theme-notch-handle,
.theme-notch:focus-within .theme-notch-handle,
.theme-notch[data-open="true"] .theme-notch-handle {
transform: translateX(-4px);
}
.theme-notch-option {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
width: 160px;
min-height: 40px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--stroke-weak);
background: var(--layer-1);
color: var(--fg);
cursor: pointer;
font-weight: 800;
font-size: 13px;
line-height: 1.1;
letter-spacing: -0.01em;
transition:
transform 140ms ease,
background 140ms ease,
border-color 140ms ease;
}
.theme-notch-option:hover {
transform: translateY(-1px);
background: var(--layer-2);
border-color: var(--stroke-mid);
}
.theme-notch-dot {
width: 9px;
height: 9px;
border-radius: 999px;
border: 2px solid var(--stroke-mid);
background: transparent;
flex: 0 0 auto;
}
.theme-notch-option[aria-checked="true"] {
border-color: color-mix(in srgb, var(--accent2) 50%, var(--stroke-mid));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent2) 18%, transparent);
}
.theme-notch-option[aria-checked="true"] .theme-notch-dot {
border-color: var(--accent2);
background: var(--accent2);
}
@media (max-width: 760px) {
.theme-notch {
right: max(10px, env(safe-area-inset-right));
}
.theme-notch-panel {
right: 60px;
width: min(80vw, 220px);
}
.theme-notch-option {
width: 100%;
}
}
@media (forced-colors: active) {
body {
background: Canvas;
color: CanvasText;
}
body::before {
display: none;
}
.site-header,
.nav,
.card,
.hero,
.theme-notch-panel,
.theme-notch-handle,
.theme-notch-option {
background: Canvas;
color: CanvasText;
border-color: CanvasText;
box-shadow: none;
}
.theme-notch-option[aria-checked="true"] {
outline: 2px solid Highlight;
outline-offset: 2px;
box-shadow: none;
}
.theme-notch-dot {
border-color: CanvasText;
}
}
html[data-theme-transition="on"] body {
transition:
background 220ms ease,
color 220ms ease;
}
html[data-theme-transition="on"] body::before {
transition: opacity 220ms ease;
}
html[data-theme-transition="on"] .site-header,
html[data-theme-transition="on"] .nav-toggle,
html[data-theme-transition="on"] .nav,
html[data-theme-transition="on"] .card,
html[data-theme-transition="on"] .cta,
html[data-theme-transition="on"] .subnav a,
html[data-theme-transition="on"] .pill,
html[data-theme-transition="on"] dialog,
html[data-theme-transition="on"] .theme-notch-panel,
html[data-theme-transition="on"] .theme-notch-handle,
html[data-theme-transition="on"] .theme-notch-option {
transition:
background-color 220ms ease,
color 220ms ease,
border-color 220ms ease,
box-shadow 220ms ease;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,
@@ -242,15 +536,15 @@ textarea:focus-visible {
flex-wrap: wrap; flex-wrap: wrap;
margin: 18px 0 8px; margin: 18px 0 8px;
padding-bottom: 6px; padding-bottom: 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08); border-bottom: 1px solid var(--stroke-weak);
color: var(--muted); color: var(--muted);
} }
.subnav a { .subnav a {
padding: 8px 10px; padding: 8px 10px;
border-radius: 999px; border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid var(--stroke-mid);
background: rgba(255, 255, 255, 0.04); background: var(--layer-1);
font-weight: 700; font-weight: 700;
font-size: 13px; font-size: 13px;
} }
@@ -271,7 +565,7 @@ textarea:focus-visible {
.prose { .prose {
line-height: 1.75; line-height: 1.75;
color: rgba(242, 244, 255, 0.9); color: color-mix(in srgb, var(--fg) 90%, transparent);
} }
.prose a { .prose a {
@@ -282,11 +576,11 @@ textarea:focus-visible {
max-width: 100%; max-width: 100%;
height: auto; height: auto;
border-radius: 16px; border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid var(--stroke-mid);
} }
.site-footer { .site-footer {
border-top: 1px solid rgba(255, 255, 255, 0.08); border-top: 1px solid var(--stroke-weak);
padding: 20px 24px; padding: 20px 24px;
text-align: center; text-align: center;
} }
@@ -302,7 +596,7 @@ textarea:focus-visible {
align-items: start; align-items: start;
padding: 28px; padding: 28px;
border: 1px solid var(--stroke); border: 1px solid var(--stroke);
background: rgba(255, 255, 255, 0.04); background: var(--layer-1);
border-radius: 18px; border-radius: 18px;
} }
@@ -334,7 +628,7 @@ textarea:focus-visible {
padding: 10px 14px; padding: 10px 14px;
border-radius: 999px; border-radius: 999px;
border: 1px solid var(--stroke); border: 1px solid var(--stroke);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03)); background: linear-gradient(180deg, var(--layer-3), color-mix(in srgb, var(--layer-1) 75%, transparent));
font-weight: 800; font-weight: 800;
letter-spacing: -0.01em; letter-spacing: -0.01em;
} }
@@ -378,21 +672,36 @@ textarea:focus-visible {
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
border-radius: 16px; border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid var(--stroke-mid);
background: rgba(255, 255, 255, 0.04); background: var(--card);
overflow: hidden; overflow: hidden;
transition: transition:
transform 120ms ease, transform 120ms ease,
background 120ms ease; background 120ms ease;
} }
button.card {
padding: 0;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
width: 100%;
}
.card-media { .card-media {
flex: 0 0 auto; flex: 0 0 auto;
} }
.card:hover { .card:hover {
transform: translateY(-2px); transform: translateY(-2px);
background: rgba(255, 255, 255, 0.06); background: var(--card2);
}
.card-media .img-shimmer-wrap {
width: 100%;
height: 180px;
border-bottom: 1px solid var(--stroke-weak);
} }
.card-media img { .card-media img {
@@ -400,16 +709,73 @@ textarea:focus-visible {
height: 180px; height: 180px;
object-fit: cover; object-fit: cover;
display: block; display: block;
border-bottom: 1px solid rgba(255, 255, 255, 0.08); border-bottom: 1px solid var(--stroke-weak);
} }
.card-placeholder { .card-placeholder {
width: 100%; width: 100%;
height: 180px; height: 180px;
background: rgba(255, 255, 255, 0.06); background: var(--layer-2);
border-bottom: 1px solid rgba(255, 255, 255, 0.08); border-bottom: 1px solid var(--stroke-weak);
} }
/* --- Image shimmer / lazy-load placeholder --- */
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.img-shimmer-wrap {
position: relative;
overflow: hidden;
background: var(--layer-2);
}
.img-shimmer-wrap::before {
content: "";
position: absolute;
inset: 0;
z-index: 1;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--fg) 10%, transparent) 35%,
color-mix(in srgb, var(--fg) 18%, transparent) 50%,
color-mix(in srgb, var(--fg) 10%, transparent) 65%,
transparent 100%
);
animation: shimmer 1.6s ease-in-out infinite;
}
.img-shimmer-wrap img {
position: relative;
z-index: 2;
}
.img-shimmer-wrap img.img-loading {
opacity: 0 !important;
}
.img-shimmer-wrap img.img-loaded {
opacity: 1;
transition: opacity 250ms ease;
}
.img-shimmer-wrap.img-error::before {
animation: none;
}
.img-shimmer-wrap.img-loaded::before {
display: none;
}
/* --- End image shimmer --- */
.card-body { .card-body {
display: flex; display: flex;
flex: 1; flex: 1;
@@ -420,7 +786,11 @@ textarea:focus-visible {
.card-content { .card-content {
flex: 1; flex: 1;
padding: 12px 12px 12px; padding: 12px 12px 12px;
background: linear-gradient(180deg, rgba(15, 27, 56, 0.75), rgba(11, 16, 32, 0.32)); background: linear-gradient(
180deg,
color-mix(in srgb, var(--surface-1) 94%, transparent),
color-mix(in srgb, var(--surface-1) 68%, transparent)
);
} }
.card-title { .card-title {
@@ -444,8 +814,8 @@ textarea:focus-visible {
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
padding: 10px 12px; padding: 10px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.08); border-top: 1px solid var(--stroke-weak);
background: rgba(11, 16, 32, 0.45); background: color-mix(in srgb, var(--surface-1) 78%, transparent);
font-size: 12px; font-size: 12px;
} }
@@ -459,8 +829,8 @@ textarea:focus-visible {
font-weight: 800; font-weight: 800;
padding: 4px 8px; padding: 4px 8px;
border-radius: 999px; border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.16); border: 1px solid var(--stroke-mid);
background: rgba(255, 255, 255, 0.06); background: var(--layer-1);
} }
.pill-youtube { .pill-youtube {
@@ -482,9 +852,9 @@ textarea:focus-visible {
.empty { .empty {
padding: 16px; padding: 16px;
border-radius: 14px; border-radius: 14px;
border: 1px dashed rgba(255, 255, 255, 0.18); border: 1px dashed var(--stroke-strong);
color: var(--muted); color: var(--muted);
background: rgba(255, 255, 255, 0.03); background: var(--layer-1);
} }
.instagram-media { .instagram-media {
@@ -505,7 +875,340 @@ textarea:focus-visible {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.card-media img, .card-media img,
.card-placeholder { .card-placeholder,
.card-media .img-shimmer-wrap {
height: 200px; height: 200px;
} }
} }
/* --- Media Modal --- */
#media-modal {
max-width: 800px;
width: calc(100vw - 48px);
max-height: 90vh;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: var(--bg1);
color: var(--fg);
padding: 0;
overflow: hidden;
box-shadow:
0 24px 80px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(255, 255, 255, 0.08) inset;
}
#media-modal::backdrop {
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(8px);
}
.media-modal-content {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
}
.media-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.media-modal-header-left {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
flex: 1;
}
.media-modal-thumb {
display: none;
width: 44px;
height: 44px;
border-radius: 14px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(0, 0, 0, 0.25);
}
.media-modal-title-wrap {
min-width: 0;
}
.media-modal-kicker {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
margin: 0 0 6px;
}
.media-modal-title {
margin: 0;
font-size: 20px;
font-weight: 800;
line-height: 1.3;
letter-spacing: -0.02em;
overflow: hidden;
text-overflow: ellipsis;
}
.media-modal-close {
flex: 0 0 auto;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.04);
color: var(--fg);
cursor: pointer;
transition:
background 120ms ease,
transform 120ms ease;
}
.media-modal-close:hover {
background: rgba(255, 255, 255, 0.08);
transform: scale(1.05);
}
.media-modal-embed-container {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: rgba(0, 0, 0, 0.4);
}
.media-modal-embed-container[data-embed-kind="spotify"],
.media-modal-embed-container[data-embed-kind="audio"],
.media-modal-embed-container[data-embed-kind="fallback"] {
margin: 14px 24px 0;
width: calc(100% - 48px);
}
.media-modal-embed-container[data-embed-kind="spotify"] {
aspect-ratio: auto;
height: 232px;
background: rgba(255, 255, 255, 0.04);
border-radius: 12px;
overflow: hidden;
}
.media-modal-embed-container[data-embed-kind="audio"] {
aspect-ratio: auto;
height: auto;
padding: 12px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03));
border-radius: 12px;
overflow: hidden;
}
.media-modal-embed-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.08);
z-index: 1;
overflow: hidden;
}
.media-modal-embed-fallback {
position: relative;
z-index: 2;
display: none;
align-items: center;
justify-content: center;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(10, 14, 28, 0.55);
color: var(--fg);
font-weight: 700;
text-decoration: none;
}
.media-modal-embed-container[data-embed-kind="fallback"] .media-modal-embed-placeholder::before,
.media-modal-embed-container[data-embed-kind="fallback"] .media-modal-embed-placeholder::after {
display: none;
animation: none;
}
.media-modal-embed-container[data-embed-kind="fallback"] .media-modal-embed-placeholder {
background: rgba(255, 255, 255, 0.04);
}
.media-modal-embed-container[data-embed-kind="fallback"] .media-modal-embed-fallback {
display: inline-flex;
}
.media-modal-embed-placeholder::before {
content: "";
position: absolute;
inset: 0;
z-index: 1;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.12) 35%,
rgba(255, 255, 255, 0.22) 50%,
rgba(255, 255, 255, 0.12) 65%,
transparent 100%
);
animation: shimmer 1.6s ease-in-out infinite;
}
.media-modal-embed-placeholder::after {
content: "";
position: relative;
z-index: 2;
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: var(--accent2);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.media-modal-embed {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
z-index: 2;
}
.media-modal-embed-container[data-embed-kind="spotify"] .media-modal-embed,
.media-modal-embed-container[data-embed-kind="spotify"] .media-modal-embed-placeholder {
border-radius: 12px;
}
.media-modal-audio {
display: none;
width: 100%;
height: 44px;
color-scheme: dark;
accent-color: var(--accent2);
}
.media-modal-embed-container[data-embed-kind="audio"] .media-modal-audio {
display: block;
}
.media-modal-audio::-webkit-media-controls-enclosure {
border-radius: 12px;
}
.media-modal-audio::-webkit-media-controls-panel {
background: rgba(10, 14, 28, 0.55);
}
.media-modal-body {
padding: 24px;
flex: 1 1 auto;
}
.media-modal-description {
margin: 0 0 16px;
line-height: 1.6;
color: var(--muted);
}
.media-modal-meta {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
font-size: 14px;
color: var(--muted);
}
.media-modal-meta span:not(:last-child)::after {
content: "•";
margin-left: 12px;
opacity: 0.5;
}
.media-modal-ctas {
display: flex;
gap: 12px;
padding: 16px 24px 24px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.media-modal-ctas .cta {
flex: 1;
text-align: center;
padding: 12px 16px;
border-radius: 14px;
border: 1px solid var(--stroke);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03));
font-weight: 800;
letter-spacing: -0.01em;
transition:
background 120ms ease,
transform 120ms ease;
}
.media-modal-ctas .cta:hover {
background: rgba(255, 255, 255, 0.08);
transform: translateY(-1px);
}
.media-modal-ctas .cta.primary {
border-color: rgba(94, 228, 255, 0.55);
background: linear-gradient(135deg, rgba(94, 228, 255, 0.24), rgba(255, 205, 74, 0.16));
box-shadow:
0 0 0 3px rgba(94, 228, 255, 0.14),
0 10px 30px rgba(0, 0, 0, 0.25);
}
@media (max-width: 760px) {
#media-modal {
max-width: 96vw;
max-height: 94vh;
width: 96vw;
}
.media-modal-header {
padding: 16px 20px;
}
.media-modal-embed-container[data-embed-kind="spotify"],
.media-modal-embed-container[data-embed-kind="audio"],
.media-modal-embed-container[data-embed-kind="fallback"] {
margin: 12px 20px 0;
width: calc(100% - 40px);
}
.media-modal-title {
font-size: 18px;
}
.media-modal-body {
padding: 20px;
}
.media-modal-ctas {
flex-direction: column;
padding: 12px 20px 20px;
}
}

View File

@@ -4,14 +4,29 @@
- Images: cache-first with bounded eviction - Images: cache-first with bounded eviction
*/ */
// Bump this value on deploy to invalidate caches. const cacheVersionFromUrl = (() => {
const CACHE_VERSION = "v1"; try {
const v = new URL(self.location.href).searchParams.get("v");
if (!v) return null;
if (!/^[a-zA-Z0-9._-]{1,64}$/.test(v)) return null;
return v;
} catch {
return null;
}
})();
const CACHE_VERSION = cacheVersionFromUrl ? `v-${cacheVersionFromUrl}` : "v4";
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.svg", "/favicon.ico", "/robots.txt"]; const assetSuffix = cacheVersionFromUrl ? `?v=${encodeURIComponent(cacheVersionFromUrl)}` : "";
const SHELL_ASSETS = ["/", `/styles/global.css${assetSuffix}`, "/favicon.png", "/robots.txt"];
// Cache.addAll() throws if there are any duplicate requests.
const SHELL_ASSETS_UNIQUE = Array.from(new Set(SHELL_ASSETS));
// 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;
@@ -58,7 +73,7 @@ self.addEventListener("install", (event) => {
event.waitUntil( event.waitUntil(
(async () => { (async () => {
const cache = await caches.open(CACHE_SHELL); const cache = await caches.open(CACHE_SHELL);
await cache.addAll(SHELL_ASSETS); await cache.addAll(SHELL_ASSETS_UNIQUE);
// Activate new worker ASAP to pick up new caching rules. // Activate new worker ASAP to pick up new caching rules.
await self.skipWaiting(); await self.skipWaiting();
})(), })(),
@@ -123,6 +138,25 @@ self.addEventListener("fetch", (event) => {
return; return;
} }
// Network-first for global CSS to avoid serving stale styling after deploy.
// (The SW already caches styles, but network-first prevents a "stuck" old CSS experience.)
if (url.origin === self.location.origin && url.pathname === "/styles/global.css") {
event.respondWith(
(async () => {
try {
const fresh = await fetch(request);
await cachePutSafe(CACHE_SHELL, request, fresh.clone());
return fresh;
} catch {
const cached = await caches.match(request);
if (cached) return cached;
return fetch(request);
}
})(),
);
return;
}
// Stale-while-revalidate for styles/scripts/fonts from same-origin. // Stale-while-revalidate for styles/scripts/fonts from same-origin.
if ( if (
url.origin === self.location.origin && url.origin === self.location.origin &&

View File

@@ -37,6 +37,41 @@ function dedupe(items: ContentItem[]): ContentItem[] {
return out; return out;
} }
function normalizeSpotifyEpisodeUrl(value: string): string | undefined {
const v = (value || "").trim();
if (!v) return undefined;
if (v.startsWith("https://open.spotify.com/episode/")) return v;
if (v.startsWith("https://open.spotify.com/embed/episode/")) {
return v.replace("/embed/episode/", "/episode/");
}
if (v.startsWith("spotify:episode:")) {
const id = v.split(":")[2];
if (!id) return undefined;
return `https://open.spotify.com/episode/${id}`;
}
if (/^[A-Za-z0-9]{10,30}$/.test(v)) {
return `https://open.spotify.com/episode/${v}`;
}
return undefined;
}
async function readPodcastSpotifyOverrideMap(logFn: (msg: string) => void) {
const mapPath = path.join(process.cwd(), "content", "podcast-spotify-map.json");
try {
const raw = await fs.readFile(mapPath, "utf8");
const parsed = JSON.parse(raw) as Record<string, string>;
if (!parsed || typeof parsed !== "object") return undefined;
return parsed;
} catch {
logFn("Podcast: spotify override map not found (content/podcast-spotify-map.json)");
return undefined;
}
}
async function main() { async function main() {
const cfg = getIngestConfigFromEnv(process.env); const cfg = getIngestConfigFromEnv(process.env);
const generatedAt = new Date().toISOString(); const generatedAt = new Date().toISOString();
@@ -101,6 +136,23 @@ async function main() {
); );
log(`Podcast: RSS ${cached ? "cache" : "live"} (${items.length} items)`); log(`Podcast: RSS ${cached ? "cache" : "live"} (${items.length} items)`);
log(`Podcast: RSS ok (${items.length} items)`); log(`Podcast: RSS ok (${items.length} items)`);
const overrideMap = await readPodcastSpotifyOverrideMap(log);
if (overrideMap) {
let overridden = 0;
for (const it of items) {
if (it.source !== "podcast") continue;
const mapped = overrideMap[it.id] || overrideMap[it.url];
const nextUrl = mapped ? normalizeSpotifyEpisodeUrl(mapped) : undefined;
if (!nextUrl) continue;
it.url = nextUrl;
overridden++;
}
if (overridden > 0) {
log(`Podcast: applied Spotify URL overrides (${overridden} items)`);
}
}
all.push(...items); all.push(...items);
} catch (e) { } catch (e) {
log(`Podcast: RSS failed (${String(e)})`); log(`Podcast: RSS failed (${String(e)})`);

View File

@@ -0,0 +1,39 @@
import "dotenv/config";
import { readFile } from "node:fs/promises";
function fail(msg: string): never {
// eslint-disable-next-line no-console
console.error(`[verify:umami] ${msg}`);
process.exit(1);
}
function info(msg: string) {
// eslint-disable-next-line no-console
console.log(`[verify:umami] ${msg}`);
}
async function main() {
const html = await readFile("dist/index.html", "utf8");
const scriptUrl = process.env.PUBLIC_UMAMI_SCRIPT_URL || "";
const websiteId = process.env.PUBLIC_UMAMI_WEBSITE_ID || "";
const expectsEnabled = Boolean(scriptUrl && websiteId);
const hasWebsiteId = html.includes('data-website-id="');
const hasScriptUrl = scriptUrl ? html.includes(`src="${scriptUrl}"`) : false;
if (expectsEnabled) {
if (!hasWebsiteId) fail(`expected Umami enabled, but data-website-id not found in dist/index.html`);
if (!hasScriptUrl)
fail(`expected Umami enabled, but src="${scriptUrl}" not found in dist/index.html`);
info("ok (umami script present)");
return;
}
// When not configured, the site should not render a script tag.
if (hasWebsiteId) fail("expected Umami disabled, but data-website-id was found in dist/index.html");
info("ok (umami disabled, no script rendered)");
}
main().catch((e) => fail(String(e)));

View File

@@ -20,17 +20,10 @@ const { categories, activeCategorySlug } = Astro.props;
> >
All All
</a> </a>
<a
class={activeCategorySlug === "__pages" ? "active" : ""} {categories
href="/blog/pages" .filter((c) => c.slug !== "uncategorized")
data-umami-event="click" .map((c) => (
data-umami-event-target_id="blog.subnav.pages"
data-umami-event-placement="blog.subnav"
data-umami-event-target_url="/blog/pages"
>
Pages
</a>
{categories.map((c) => (
<a <a
class={activeCategorySlug === c.slug ? "active" : ""} class={activeCategorySlug === c.slug ? "active" : ""}
href={`/blog/category/${c.slug}`} href={`/blog/category/${c.slug}`}

View File

@@ -35,18 +35,34 @@ const umamiType =
: undefined; : undefined;
const umamiTitle = umamiType ? truncate(item.title, 160) : undefined; const umamiTitle = umamiType ? truncate(item.title, 160) : undefined;
---
<StandardCard // Determine if card should open modal (youtube/podcast) or link normally (other sources)
href={item.url} const isModalTrigger = item.source === "youtube" || item.source === "podcast";
title={item.title} const mode = isModalTrigger ? "modal" : "link";
summary={item.summary}
imageUrl={item.thumbnailUrl} // Build link attrs based on mode
dateLabel={dateLabel} const linkAttrs = isModalTrigger
viewsLabel={item.metrics?.views !== undefined ? `${item.metrics.views.toLocaleString()} views` : undefined} ? {
sourceLabel={item.source} // Modal trigger: pass all item data via data-* attributes
isExternal={true} "data-item-id": item.id,
linkAttrs={{ "data-item-source": item.source,
"data-item-url": item.url,
"data-item-title": item.title,
"data-item-summary": item.summary || "",
"data-item-published-at": item.publishedAt,
"data-item-thumbnail-url": item.thumbnailUrl || "",
"data-item-audio-url": item.source === "podcast" ? item.audioUrl || "" : "",
"data-item-views": item.metrics?.views?.toString() || "",
// Umami tracking: media_preview instead of outbound_click
"data-umami-event": "media_preview",
"data-umami-event-target_id": targetId,
"data-umami-event-placement": placement,
"data-umami-event-title": umamiTitle,
"data-umami-event-type": umamiType,
"data-umami-event-source": item.source,
}
: {
// Normal link: outbound_click tracking
"data-umami-event": "outbound_click", "data-umami-event": "outbound_click",
"data-umami-event-target_id": targetId, "data-umami-event-target_id": targetId,
"data-umami-event-placement": placement, "data-umami-event-placement": placement,
@@ -56,5 +72,18 @@ const umamiTitle = umamiType ? truncate(item.title, 160) : undefined;
"data-umami-event-domain": domain || "unknown", "data-umami-event-domain": domain || "unknown",
"data-umami-event-source": item.source, "data-umami-event-source": item.source,
"data-umami-event-ui_placement": "content_card", "data-umami-event-ui_placement": "content_card",
}} };
---
<StandardCard
href={isModalTrigger ? undefined : item.url}
title={item.title}
summary={item.summary}
imageUrl={item.thumbnailUrl}
dateLabel={dateLabel}
viewsLabel={item.metrics?.views !== undefined ? `${item.metrics.views.toLocaleString()} views` : undefined}
sourceLabel={item.source}
isExternal={!isModalTrigger}
mode={mode}
linkAttrs={linkAttrs}
/> />

View File

@@ -0,0 +1,398 @@
---
import { LINKS } from "../lib/links";
---
<dialog id="media-modal" aria-modal="true" aria-labelledby="media-modal-title">
<div class="media-modal-content">
<div class="media-modal-header">
<div class="media-modal-header-left">
<img class="media-modal-thumb" alt="" loading="lazy" />
<div class="media-modal-title-wrap">
<div class="media-modal-kicker"></div>
<h2 id="media-modal-title" class="media-modal-title"></h2>
</div>
</div>
<button type="button" class="media-modal-close" aria-label="Close" data-close-method="button">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="media-modal-embed-container" data-embed-kind="">
<div class="media-modal-embed-placeholder">
<a class="media-modal-embed-fallback" target="_blank" rel="noopener noreferrer"></a>
</div>
<iframe
class="media-modal-embed"
title="Media embed"
width="100%"
frameborder="0"
allowfullscreen
></iframe>
<audio class="media-modal-audio" controls></audio>
</div>
<div class="media-modal-body">
<p class="media-modal-description"></p>
<div class="media-modal-meta">
<span class="media-modal-date"></span>
<span class="media-modal-views"></span>
</div>
</div>
<div class="media-modal-ctas">
<a class="cta primary media-modal-cta-follow" target="_blank" rel="me noopener noreferrer"></a>
<a class="cta media-modal-cta-view" target="_blank" rel="me noopener noreferrer"></a>
</div>
</div>
</dialog>
<script is:inline define:vars={{ youtubeChannelUrl: LINKS.youtubeChannel, podcastUrl: LINKS.podcast }}>
(function() {
const dialog = document.getElementById("media-modal");
const dialogContent = dialog.querySelector(".media-modal-content");
const kickerEl = dialog.querySelector(".media-modal-kicker");
const thumbEl = dialog.querySelector(".media-modal-thumb");
const titleEl = dialog.querySelector(".media-modal-title");
const embedContainer = dialog.querySelector(".media-modal-embed-container");
const embedPlaceholder = dialog.querySelector(".media-modal-embed-placeholder");
const embedFallback = dialog.querySelector(".media-modal-embed-fallback");
const iframe = dialog.querySelector(".media-modal-embed");
const audio = dialog.querySelector(".media-modal-audio");
const descriptionEl = dialog.querySelector(".media-modal-description");
const dateEl = dialog.querySelector(".media-modal-date");
const viewsEl = dialog.querySelector(".media-modal-views");
const followCta = dialog.querySelector(".media-modal-cta-follow");
const viewCta = dialog.querySelector(".media-modal-cta-view");
const closeBtn = dialog.querySelector(".media-modal-close");
let triggerElement = null;
let currentTargetId = null;
// Extract video ID from YouTube URL
function extractYoutubeId(url) {
try {
const u = new URL(url);
// Handle youtube.com/watch?v=ID
if (u.hostname.includes("youtube.com") && u.searchParams.has("v")) {
return u.searchParams.get("v");
}
// Handle youtu.be/ID
if (u.hostname.includes("youtu.be")) {
return u.pathname.slice(1).split("/")[0];
}
} catch (e) {}
return null;
}
// Extract Spotify episode ID from URL
function extractSpotifyEpisodeId(input) {
try {
const u = new URL(String(input));
if (!u.hostname.includes("spotify.com")) return null;
const match = u.pathname.match(/\/episode\/([^/?]+)/);
return match ? match[1] : null;
} catch (e) {}
return null;
}
function extractSpotifyEpisodeIdFromGuid(guid) {
const s = String(guid || "").trim();
if (!s) return null;
// spotify:episode:EPISODE_ID
if (s.startsWith("spotify:episode:")) {
const parts = s.split(":");
return parts.length >= 3 ? parts[2] : null;
}
// URL form
const fromUrl = extractSpotifyEpisodeId(s);
if (fromUrl) return fromUrl;
return null;
}
function getEmbed(source, itemUrl, itemId) {
if (source === "youtube") {
const videoId = extractYoutubeId(itemUrl);
if (!videoId) return null;
return {
kind: "youtube",
src: `https://www.youtube.com/embed/${videoId}?rel=0&modestbranding=1`,
};
}
if (source === "podcast") {
const episodeId =
extractSpotifyEpisodeId(itemUrl) ||
extractSpotifyEpisodeIdFromGuid(itemId) ||
extractSpotifyEpisodeId(itemId);
if (!episodeId) return null;
return {
kind: "spotify",
src: `https://open.spotify.com/embed/episode/${episodeId}`,
height: 232,
};
}
return null;
}
// Add UTM parameters to URL
function withUtm(url, utmParams) {
const u = new URL(url);
for (const [key, value] of Object.entries(utmParams)) {
if (value) u.searchParams.set(key, value);
}
return u.toString();
}
function withYoutubeSubscribePrompt(url) {
try {
const u = new URL(url);
u.searchParams.set("sub_confirmation", "1");
return u.toString();
} catch {
return url;
}
}
// Stop playback by blanking the iframe
function stopPlayback() {
iframe.src = "about:blank";
embedPlaceholder.style.display = "block";
embedContainer.dataset.embedKind = "";
iframe.style.display = "";
embedFallback.style.display = "none";
embedFallback.removeAttribute("href");
audio.pause();
audio.removeAttribute("src");
audio.style.display = "none";
thumbEl.removeAttribute("src");
thumbEl.style.display = "none";
kickerEl.textContent = "";
iframe.removeAttribute("allow");
iframe.removeAttribute("height");
}
// Close modal
function closeModal(method) {
if (!dialog.open) return;
stopPlayback();
dialog.close();
// Emit media_preview_close event if Umami is available
if (typeof window.umami !== "undefined" && currentTargetId) {
window.umami.track("media_preview_close", {
target_id: currentTargetId,
close_method: method || "unknown"
});
}
// Restore focus to trigger element
if (triggerElement) {
triggerElement.focus();
triggerElement = null;
}
currentTargetId = null;
}
// Open and populate modal
function openModal(cardEl) {
// Store trigger for focus return
triggerElement = cardEl;
// Read data attributes
const itemId = cardEl.dataset.itemId;
const source = cardEl.dataset.itemSource;
const url = cardEl.dataset.itemUrl;
const audioUrl = cardEl.dataset.itemAudioUrl;
const title = cardEl.dataset.itemTitle;
const summary = cardEl.dataset.itemSummary;
const publishedAt = cardEl.dataset.itemPublishedAt;
const thumbnailUrl = cardEl.dataset.itemThumbnailUrl;
const views = cardEl.dataset.itemViews;
// Store target ID for close tracking
currentTargetId = cardEl.dataset.umamiEventTargetId;
kickerEl.textContent = source === "podcast" ? "Podcast" : source === "youtube" ? "Video" : "";
if (source === "podcast" && thumbnailUrl) {
thumbEl.src = thumbnailUrl;
thumbEl.style.display = "block";
} else {
thumbEl.removeAttribute("src");
thumbEl.style.display = "none";
}
// Populate title
titleEl.textContent = title || "";
// Populate description
descriptionEl.textContent = summary || "";
// Populate date
if (publishedAt) {
const d = new Date(publishedAt);
const formatted = Number.isFinite(d.valueOf())
? d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
: "";
dateEl.textContent = formatted;
dateEl.style.display = formatted ? "" : "none";
} else {
dateEl.style.display = "none";
}
// Populate views
if (views && views !== "") {
const viewsNum = parseInt(views, 10);
viewsEl.textContent = `${viewsNum.toLocaleString()} views`;
viewsEl.style.display = "";
} else {
viewsEl.style.display = "none";
}
// Construct embed
const embed = getEmbed(source, url, itemId);
if (embed) {
embedContainer.dataset.embedKind = embed.kind;
embedPlaceholder.style.display = "block";
embedFallback.style.display = "none";
embedFallback.removeAttribute("href");
iframe.style.display = "";
if (embed.kind === "spotify") {
iframe.setAttribute(
"allow",
"autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture",
);
if (embed.height) iframe.setAttribute("height", String(embed.height));
} else {
iframe.setAttribute(
"allow",
"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture",
);
iframe.removeAttribute("height");
}
iframe.src = embed.src;
iframe.addEventListener("load", function onLoad() {
embedPlaceholder.style.display = "none";
iframe.removeEventListener("load", onLoad);
});
embedContainer.style.display = "block";
} else {
// No embed available
if (source === "podcast") {
embedContainer.style.display = "block";
iframe.src = "about:blank";
iframe.style.display = "none";
if (audioUrl) {
embedContainer.dataset.embedKind = "audio";
embedPlaceholder.style.display = "none";
embedFallback.style.display = "none";
audio.style.display = "block";
audio.src = audioUrl;
} else {
embedContainer.dataset.embedKind = "fallback";
embedPlaceholder.style.display = "flex";
embedFallback.style.display = "inline-flex";
embedFallback.href = url;
embedFallback.textContent = "Listen on Spotify";
audio.style.display = "none";
}
} else {
embedContainer.style.display = "none";
}
}
const platform = source === "youtube" ? "youtube" : "spotify";
const channelUrl = source === "youtube" ? youtubeChannelUrl : podcastUrl;
const followAction = source === "youtube" ? "subscribe" : "follow";
const viewAction = source === "youtube" ? "view" : "listen";
const followLabel = source === "youtube" ? "Subscribe on YouTube" : "Follow on Spotify";
const viewLabel = source === "youtube" ? "View on YouTube" : "Listen on Spotify";
const followBaseUrl = source === "youtube" ? withYoutubeSubscribePrompt(channelUrl) : channelUrl;
const followUrl = withUtm(followBaseUrl, {
utm_source: "website",
utm_medium: "cta",
utm_campaign: "social-acquisition",
utm_content: `${platform}:media_modal`,
});
followCta.href = followUrl;
followCta.textContent = followLabel;
followCta.setAttribute("data-umami-event", "cta_click");
followCta.setAttribute("data-umami-event-target_id", `modal.cta.${followAction}.${platform}`);
followCta.setAttribute("data-umami-event-placement", "media_modal");
followCta.setAttribute("data-umami-event-platform", platform);
followCta.setAttribute("data-umami-event-target_url", channelUrl);
const viewUrl = withUtm(url, {
utm_source: "website",
utm_medium: "cta",
utm_campaign: "social-acquisition",
utm_content: `${platform}:media_modal`,
});
viewCta.href = viewUrl;
viewCta.textContent = viewLabel;
viewCta.setAttribute("data-umami-event", "cta_click");
viewCta.setAttribute("data-umami-event-target_id", `modal.cta.${viewAction}.${platform}`);
viewCta.setAttribute("data-umami-event-placement", "media_modal");
viewCta.setAttribute("data-umami-event-platform", platform);
viewCta.setAttribute("data-umami-event-target_url", url);
// Open the dialog
dialog.showModal();
}
// Listen for clicks on modal-trigger cards
document.addEventListener("click", function(e) {
const card = e.target.closest("button.card[data-item-id]");
if (card) {
e.preventDefault();
openModal(card);
}
});
// Close button
closeBtn.addEventListener("click", function() {
closeModal("button");
});
// Backdrop click
dialog.addEventListener("click", function(e) {
if (e.target === dialog) {
closeModal("backdrop");
}
});
// Escape key (native dialog handles the close, we hook into the close event)
dialog.addEventListener("close", function() {
// If dialog was closed but we didn't call closeModal explicitly (i.e., Escape key)
if (dialog.open === false && currentTargetId) {
stopPlayback();
if (typeof window.umami !== "undefined") {
window.umami.track("media_preview_close", {
target_id: currentTargetId,
close_method: "escape"
});
}
if (triggerElement) {
triggerElement.focus();
triggerElement = null;
}
currentTargetId = null;
}
});
})();
</script>

View File

@@ -1,6 +1,6 @@
--- ---
type Props = { type Props = {
href: string; href?: string;
title: string; title: string;
summary?: string; summary?: string;
imageUrl?: string; imageUrl?: string;
@@ -9,6 +9,7 @@ type Props = {
sourceLabel: string; sourceLabel: string;
isExternal?: boolean; isExternal?: boolean;
linkAttrs?: Record<string, any>; linkAttrs?: Record<string, any>;
mode?: "link" | "modal";
}; };
const { const {
@@ -21,6 +22,7 @@ const {
sourceLabel, sourceLabel,
isExternal, isExternal,
linkAttrs, linkAttrs,
mode = "link",
} = Astro.props; } = Astro.props;
function truncate(s: string, n: number) { function truncate(s: string, n: number) {
@@ -33,17 +35,30 @@ function truncate(s: string, n: number) {
const summaryText = truncate(summary || "", 180); const summaryText = truncate(summary || "", 180);
const Element = mode === "modal" ? "button" : "a";
const elementProps = mode === "modal"
? { type: "button", ...linkAttrs }
: {
href,
target: isExternal ? "_blank" : undefined,
rel: isExternal ? "noopener noreferrer" : undefined,
...linkAttrs
};
--- ---
<a <Element
class="card" class="card"
href={href} {...elementProps}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
{...(linkAttrs || {})}
> >
<div class="card-media"> <div class="card-media">
{imageUrl ? <img src={imageUrl} alt="" loading="lazy" /> : <div class="card-placeholder" />} {imageUrl ? (
<div class="img-shimmer-wrap">
<img src={imageUrl} alt="" loading="lazy" class="img-loading" />
</div>
) : (
<div class="card-placeholder" />
)}
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -60,4 +75,4 @@ const summaryText = truncate(summary || "", 180);
<span class={`pill pill-${sourceLabel}`}>{sourceLabel}</span> <span class={`pill pill-${sourceLabel}`}>{sourceLabel}</span>
</div> </div>
</div> </div>
</a> </Element>

2
site/src/env.d.ts vendored
View File

@@ -4,6 +4,8 @@ interface ImportMetaEnv {
readonly PUBLIC_SITE_URL?: string; readonly PUBLIC_SITE_URL?: string;
readonly PUBLIC_UMAMI_SCRIPT_URL?: string; readonly PUBLIC_UMAMI_SCRIPT_URL?: string;
readonly PUBLIC_UMAMI_WEBSITE_ID?: string; readonly PUBLIC_UMAMI_WEBSITE_ID?: string;
readonly PUBLIC_ENABLE_SW?: string;
readonly PUBLIC_ASSET_VERSION?: string;
} }
interface ImportMeta { interface ImportMeta {

View File

@@ -13,6 +13,8 @@ const cfg = getPublicConfig();
const siteUrl = (cfg.siteUrl || "http://localhost:4321").replace(/\/$/, ""); const siteUrl = (cfg.siteUrl || "http://localhost:4321").replace(/\/$/, "");
const canonicalUrl = `${siteUrl}${canonicalPath.startsWith("/") ? canonicalPath : `/${canonicalPath}`}`; const canonicalUrl = `${siteUrl}${canonicalPath.startsWith("/") ? canonicalPath : `/${canonicalPath}`}`;
const assetSuffix = cfg.assetVersion ? `?v=${encodeURIComponent(cfg.assetVersion)}` : "";
--- ---
<!doctype html> <!doctype html>
@@ -37,8 +39,8 @@ const canonicalUrl = `${siteUrl}${canonicalPath.startsWith("/") ? canonicalPath
<meta name="twitter:description" content={description} /> <meta name="twitter:description" content={description} />
{ogImageUrl ? <meta name="twitter:image" content={ogImageUrl} /> : null} {ogImageUrl ? <meta name="twitter:image" content={ogImageUrl} /> : null}
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.png" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.png" />
<!-- Display-friendly font (swap to avoid blocking render). --> <!-- Display-friendly font (swap to avoid blocking render). -->
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -48,7 +50,64 @@ const canonicalUrl = `${siteUrl}${canonicalPath.startsWith("/") ? canonicalPath
rel="stylesheet" rel="stylesheet"
/> />
<link rel="stylesheet" href="/styles/global.css" /> <link rel="stylesheet" href={`/styles/global.css${assetSuffix}`} />
<script is:inline>
(() => {
const THEME_KEY = "site.theme";
const COOKIE_KEY = "site_theme";
const validate = (v) => (v === "dark" || v === "light" || v === "high-contrast" ? v : null);
const readStored = () => {
try {
return validate(window.localStorage.getItem(THEME_KEY));
} catch {
return null;
}
};
const readCookie = () => {
try {
const parts = document.cookie ? document.cookie.split(";") : [];
for (let i = 0; i < parts.length; i++) {
const p = parts[i].trim();
if (!p) continue;
if (!p.startsWith(COOKIE_KEY + "=")) continue;
const raw = p.slice((COOKIE_KEY + "=").length);
try {
return validate(decodeURIComponent(raw));
} catch {
return validate(raw);
}
}
return null;
} catch {
return null;
}
};
const forcedColors = () => {
try {
return window.matchMedia && window.matchMedia("(forced-colors: active)").matches;
} catch {
return false;
}
};
const prefersLight = () => {
try {
return window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches;
} catch {
return false;
}
};
const stored = readStored() || readCookie();
const theme = stored || (forcedColors() ? "high-contrast" : prefersLight() ? "light" : "dark");
document.documentElement.dataset.theme = theme;
})();
</script>
{ {
cfg.umami ? ( cfg.umami ? (
@@ -58,19 +117,23 @@ 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).
import.meta.env.PROD ? ( // Allow opting out via PUBLIC_ENABLE_SW="false".
<script is:inline> import.meta.env.PROD && import.meta.env.PUBLIC_ENABLE_SW !== "false" ? (
{` <script is:inline define:vars={{ assetSuffix }}>
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(() => { const swUrl = "/sw.js" + assetSuffix;
// noop: SW is progressive enhancement navigator.serviceWorker
.register(swUrl, { scope: "/", updateViaCache: "none" })
.catch((err) => {
// Progressive enhancement; keep failures non-fatal.
console.warn("Service worker registration failed", err);
}); });
}); });
} }
`}
</script> </script>
) : null ) : null
} }
</head> </head>
@@ -130,6 +193,62 @@ const canonicalUrl = `${siteUrl}${canonicalPath.startsWith("/") ? canonicalPath
</nav> </nav>
</header> </header>
<aside class="theme-notch" aria-label="Theme" data-theme-notch data-open="false">
<div
class="theme-notch-panel"
id="theme-notch-panel"
role="radiogroup"
aria-label="Theme selector"
>
<button type="button" class="theme-notch-option" data-theme-option="dark" role="radio" aria-checked="false">
<span class="theme-notch-dot" aria-hidden="true"></span>
<span class="theme-notch-text">Dark</span>
</button>
<button type="button" class="theme-notch-option" data-theme-option="light" role="radio" aria-checked="false">
<span class="theme-notch-dot" aria-hidden="true"></span>
<span class="theme-notch-text">Light</span>
</button>
<button
type="button"
class="theme-notch-option"
data-theme-option="high-contrast"
role="radio"
aria-checked="false"
>
<span class="theme-notch-dot" aria-hidden="true"></span>
<span class="theme-notch-text">Contrast</span>
</button>
</div>
<button
type="button"
class="theme-notch-handle"
aria-label="Theme"
aria-controls="theme-notch-panel"
aria-expanded="false"
data-theme-notch-handle
>
<span class="theme-notch-glyph" aria-hidden="true">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="4" y1="6" x2="20" y2="6"></line>
<line x1="4" y1="12" x2="20" y2="12"></line>
<line x1="4" y1="18" x2="20" y2="18"></line>
<circle cx="9" cy="6" r="2"></circle>
<circle cx="15" cy="12" r="2"></circle>
<circle cx="11" cy="18" r="2"></circle>
</svg>
</span>
</button>
</aside>
<main class="container" id="main-content"> <main class="container" id="main-content">
<slot /> <slot />
</main> </main>
@@ -204,5 +323,193 @@ const canonicalUrl = `${siteUrl}${canonicalPath.startsWith("/") ? canonicalPath
mql.addEventListener("change", () => setOpen(!mql.matches)); mql.addEventListener("change", () => setOpen(!mql.matches));
})(); })();
</script> </script>
<script is:inline>
(() => {
const THEME_KEY = "site.theme";
const COOKIE_KEY = "site_theme";
const ONE_YEAR_SECONDS = 31536000;
const validate = (v) => (v === "dark" || v === "light" || v === "high-contrast" ? v : null);
const setCookieTheme = (theme) => {
const v = validate(theme);
if (!v) return;
const secure = window.location && window.location.protocol === "https:" ? "; Secure" : "";
const cookie = `${COOKIE_KEY}=${encodeURIComponent(v)}; Max-Age=${ONE_YEAR_SECONDS}; Path=/; SameSite=Lax${secure}`;
document.cookie = cookie;
};
const setTheme = (next, opts) => {
const theme = validate(next);
if (!theme) return;
const prevTheme = validate(document.documentElement.dataset.theme);
if (prevTheme === theme) return;
if (opts && opts.withTransition) {
document.documentElement.dataset.themeTransition = "on";
}
document.documentElement.dataset.theme = theme;
try {
window.localStorage.setItem(THEME_KEY, theme);
} catch {
// ignore
}
try {
setCookieTheme(theme);
} catch {
// ignore
}
try {
if (typeof window.umami !== "undefined") {
const payload = {
target_id: `theme.switch.${theme}`,
placement: "theme_notch",
theme,
};
if (prevTheme) payload.prev_theme = prevTheme;
window.umami.track("theme_switch", payload);
}
} catch {
// ignore
}
if (opts && opts.withTransition) {
window.setTimeout(() => {
delete document.documentElement.dataset.themeTransition;
}, 260);
}
};
const notch = document.querySelector("[data-theme-notch]");
const handle = document.querySelector("[data-theme-notch-handle]");
const syncRadios = (theme) => {
const options = document.querySelectorAll(".theme-notch-option[data-theme-option]");
for (let i = 0; i < options.length; i++) {
const el = options[i];
const v = el.getAttribute("data-theme-option");
el.setAttribute("aria-checked", v === theme ? "true" : "false");
}
};
const setOpen = (open) => {
if (!notch || !handle) return;
notch.dataset.open = open ? "true" : "false";
handle.setAttribute("aria-expanded", open ? "true" : "false");
};
const updateNotchTop = () => {
const header = document.querySelector(".site-header");
const subnav = document.querySelector(".subnav");
const headerRect = header ? header.getBoundingClientRect() : null;
const headerH = headerRect ? headerRect.height : 0;
let top = headerH + 12;
if (subnav) {
const r = subnav.getBoundingClientRect();
if (r.top >= 0 && r.top < headerH + 140) {
top = Math.max(top, r.bottom + 12);
}
}
document.documentElement.style.setProperty("--theme-notch-top", `${Math.round(top)}px`);
};
const initialTheme = validate(document.documentElement.dataset.theme) || "dark";
syncRadios(initialTheme);
updateNotchTop();
window.addEventListener("resize", () => updateNotchTop());
window.addEventListener("scroll", () => updateNotchTop(), { passive: true });
if (handle) {
handle.addEventListener("click", () => {
const open = notch && notch.dataset.open === "true";
setOpen(!open);
if (!open) {
const first = notch && notch.querySelector(".theme-notch-option");
if (first instanceof HTMLElement) first.focus({ preventScroll: true });
}
});
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
setOpen(false);
if (handle) handle.focus({ preventScroll: true });
return;
}
const active = document.activeElement;
if (!active || !(active instanceof HTMLElement)) return;
if (!active.classList.contains("theme-notch-option")) return;
if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
const options = Array.from(
document.querySelectorAll(".theme-notch-option[data-theme-option]"),
);
const idx = options.indexOf(active);
if (idx < 0) return;
const dir = e.key === "ArrowDown" || e.key === "ArrowRight" ? 1 : -1;
const nextEl = options[(idx + dir + options.length) % options.length];
nextEl.focus({ preventScroll: true });
const v = nextEl.getAttribute("data-theme-option");
setTheme(v, { withTransition: true });
const theme = validate(document.documentElement.dataset.theme) || "dark";
syncRadios(theme);
});
document.addEventListener("click", (e) => {
const t = e.target;
if (!t || !t.closest) return;
const opt = t.closest(".theme-notch-option[data-theme-option]");
if (opt) {
const next = opt.getAttribute("data-theme-option");
setTheme(next, { withTransition: true });
const active = validate(document.documentElement.dataset.theme) || "dark";
syncRadios(active);
return;
}
if (notch && notch.dataset.open === "true") {
if (handle && t.closest("[data-theme-notch-handle]")) return;
if (t.closest("[data-theme-notch]")) return;
setOpen(false);
}
});
})();
</script>
<script is:inline>
(() => {
function reveal(img) {
img.classList.remove("img-loading");
img.classList.add("img-loaded");
var wrap = img.closest(".img-shimmer-wrap");
if (wrap) wrap.classList.add("img-loaded");
}
var imgs = document.querySelectorAll("img.img-loading");
for (var i = 0; i < imgs.length; i++) {
(function(img) {
if (img.complete && img.naturalWidth > 0) {
reveal(img);
return;
}
img.addEventListener("load", function() { reveal(img); });
img.addEventListener("error", function() {
var wrap = img.closest(".img-shimmer-wrap");
if (wrap) wrap.classList.add("img-error");
});
})(imgs[i]);
}
})();
</script>
</body> </body>
</html> </html>

View File

@@ -1,5 +1,6 @@
type PublicConfig = { type PublicConfig = {
siteUrl?: string; siteUrl?: string;
assetVersion?: string;
umami?: { umami?: {
scriptUrl: string; scriptUrl: string;
websiteId: string; websiteId: string;
@@ -23,11 +24,13 @@ type IngestConfig = {
export function getPublicConfig(): PublicConfig { export function getPublicConfig(): PublicConfig {
const siteUrl = import.meta.env.PUBLIC_SITE_URL; const siteUrl = import.meta.env.PUBLIC_SITE_URL;
const assetVersion = import.meta.env.PUBLIC_ASSET_VERSION;
const scriptUrl = import.meta.env.PUBLIC_UMAMI_SCRIPT_URL; const scriptUrl = import.meta.env.PUBLIC_UMAMI_SCRIPT_URL;
const websiteId = import.meta.env.PUBLIC_UMAMI_WEBSITE_ID; const websiteId = import.meta.env.PUBLIC_UMAMI_WEBSITE_ID;
return { return {
siteUrl, siteUrl,
assetVersion,
umami: scriptUrl && websiteId ? { scriptUrl, websiteId } : undefined, umami: scriptUrl && websiteId ? { scriptUrl, websiteId } : undefined,
}; };
} }

View File

@@ -12,6 +12,7 @@ export type ContentItem = {
summary?: string; summary?: string;
publishedAt: string; // ISO-8601 publishedAt: string; // ISO-8601
thumbnailUrl?: string; thumbnailUrl?: string;
audioUrl?: string;
metrics?: ContentMetrics; metrics?: ContentMetrics;
}; };

View File

@@ -26,6 +26,7 @@ export function normalizePodcastFeedItems(items: any[], limit: number): ContentI
const out = (items || []).slice(0, limit).map((it) => { const out = (items || []).slice(0, limit).map((it) => {
const url = it.link || ""; const url = it.link || "";
const id = (it.guid || it.id || url).toString(); const id = (it.guid || it.id || url).toString();
const audioUrl = (it.enclosure?.url || "").toString();
const publishedAt = (it.isoDate || it.pubDate || new Date(0).toISOString()).toString(); const publishedAt = (it.isoDate || it.pubDate || new Date(0).toISOString()).toString();
const summary = truncate( const summary = truncate(
(it.contentSnippet || (it.contentSnippet ||
@@ -44,6 +45,7 @@ export function normalizePodcastFeedItems(items: any[], limit: number): ContentI
summary: summary || undefined, summary: summary || undefined,
publishedAt: new Date(publishedAt).toISOString(), publishedAt: new Date(publishedAt).toISOString(),
thumbnailUrl: (it.itunes?.image || undefined) as string | undefined, thumbnailUrl: (it.itunes?.image || undefined) as string | undefined,
audioUrl: audioUrl || undefined,
}; };
}); });

View File

@@ -1,5 +1,5 @@
export const LINKS = { export const LINKS = {
youtubeChannel: "https://www.youtube.com/santhoshj", youtubeChannel: "https://www.youtube.com/santhoshj",
instagramProfile: "https://www.instagram.com/santhoshjanan/", instagramProfile: "https://www.instagram.com/santhoshjanan/",
podcast: "https://podcasters.spotify.com/pod/show/irregularmind", // default; override in CTA props if needed podcast: "https://creators.spotify.com/pod/profile/the-irregular-mind/",
}; };

View File

@@ -45,12 +45,15 @@ const metaDescription = (page.excerpt || "").slice(0, 160);
</a> </a>
</div> </div>
{page.featuredImageUrl ? ( {page.featuredImageUrl ? (
<div class="img-shimmer-wrap" style="width: 100%; max-height: 420px; border-radius: 16px; overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.12);">
<img <img
src={page.featuredImageUrl} src={page.featuredImageUrl}
alt="" alt=""
loading="lazy" loading="lazy"
style="width: 100%; max-height: 420px; object-fit: cover; border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.12);" class="img-loading"
style="width: 100%; max-height: 420px; object-fit: cover; display: block;"
/> />
</div>
) : null} ) : null}
<div class="prose" set:html={page.contentHtml} /> <div class="prose" set:html={page.contentHtml} />
</section> </section>

View File

@@ -48,12 +48,15 @@ const metaDescription = (post.excerpt || "").slice(0, 160);
{new Date(post.publishedAt).toLocaleDateString()} {new Date(post.publishedAt).toLocaleDateString()}
</p> </p>
{post.featuredImageUrl ? ( {post.featuredImageUrl ? (
<div class="img-shimmer-wrap" style="width: 100%; max-height: 420px; border-radius: 16px; overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.12);">
<img <img
src={post.featuredImageUrl} src={post.featuredImageUrl}
alt="" alt=""
loading="lazy" loading="lazy"
style="width: 100%; max-height: 420px; object-fit: cover; border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.12);" class="img-loading"
style="width: 100%; max-height: 420px; object-fit: cover; display: block;"
/> />
</div>
) : null} ) : null}
<div class="prose" set:html={post.contentHtml} /> <div class="prose" set:html={post.contentHtml} />
</section> </section>

View File

@@ -3,6 +3,7 @@ import BaseLayout from "../layouts/BaseLayout.astro";
import CtaLink from "../components/CtaLink.astro"; import CtaLink from "../components/CtaLink.astro";
import ContentCard from "../components/ContentCard.astro"; import ContentCard from "../components/ContentCard.astro";
import InstagramEmbed from "../components/InstagramEmbed.astro"; import InstagramEmbed from "../components/InstagramEmbed.astro";
import MediaModal from "../components/MediaModal.astro";
import { readContentCache } from "../lib/content/cache"; import { readContentCache } from "../lib/content/cache";
import { import {
newestItems, newestItems,
@@ -22,6 +23,19 @@ const ig = instagramPosts(cache).slice(0, 6);
const pods = podcastEpisodes(cache) const pods = podcastEpisodes(cache)
.slice(0, 6) .slice(0, 6)
.sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt)); .sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt));
const options = {
timeZone: 'America/New_York',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false, // Change to true for 12-hour format
};
const newYorkTime = new Date(cache.generatedAt).toLocaleString('en-US', options);
--- ---
<BaseLayout <BaseLayout
@@ -57,7 +71,7 @@ const pods = podcastEpisodes(cache)
/> />
</div> </div>
<p class="muted" style="margin-top: 14px;"> <p class="muted" style="margin-top: 14px;">
Last updated: {new Date(cache.generatedAt).toLocaleString()} Last updated: {`${newYorkTime} EST`}
</p> </p>
</div> </div>
<div class="empty"> <div class="empty">
@@ -227,4 +241,6 @@ const pods = podcastEpisodes(cache)
<CtaLink platform="podcast" placement="footer_cta" url={LINKS.podcast} label="Podcast" /> <CtaLink platform="podcast" placement="footer_cta" url={LINKS.podcast} label="Podcast" />
</div> </div>
</section> </section>
<MediaModal />
</BaseLayout> </BaseLayout>

View File

@@ -1,6 +1,7 @@
--- ---
import BaseLayout from "../layouts/BaseLayout.astro"; import BaseLayout from "../layouts/BaseLayout.astro";
import ContentCard from "../components/ContentCard.astro"; import ContentCard from "../components/ContentCard.astro";
import MediaModal from "../components/MediaModal.astro";
import { readContentCache } from "../lib/content/cache"; import { readContentCache } from "../lib/content/cache";
import { podcastEpisodes } from "../lib/content/selectors"; import { podcastEpisodes } from "../lib/content/selectors";
@@ -35,4 +36,6 @@ const episodes = podcastEpisodes(cache).sort(
) )
} }
</section> </section>
<MediaModal />
</BaseLayout> </BaseLayout>

View File

@@ -1,6 +1,7 @@
--- ---
import BaseLayout from "../layouts/BaseLayout.astro"; import BaseLayout from "../layouts/BaseLayout.astro";
import ContentCard from "../components/ContentCard.astro"; import ContentCard from "../components/ContentCard.astro";
import MediaModal from "../components/MediaModal.astro";
import { readContentCache } from "../lib/content/cache"; import { readContentCache } from "../lib/content/cache";
import { youtubeVideos } from "../lib/content/selectors"; import { youtubeVideos } from "../lib/content/selectors";
@@ -35,4 +36,6 @@ const videos = youtubeVideos(cache).sort(
) )
} }
</section> </section>
<MediaModal />
</BaseLayout> </BaseLayout>

View File

@@ -22,10 +22,25 @@ describe("umami event attributes", () => {
expect(src).toContain("data-umami-event-placement"); expect(src).toContain("data-umami-event-placement");
}); });
it("instruments content cards using outbound_click", async () => { it("instruments youtube/podcast content cards using media_preview", async () => {
const src = await read("src/components/ContentCard.astro");
expect(src).toContain('"data-umami-event": "media_preview"');
expect(src).toContain("data-umami-event-target_id");
expect(src).toContain("data-umami-event-source");
});
it("instruments other content cards using outbound_click", async () => {
const src = await read("src/components/ContentCard.astro"); const src = await read("src/components/ContentCard.astro");
expect(src).toContain('"data-umami-event": "outbound_click"'); expect(src).toContain('"data-umami-event": "outbound_click"');
expect(src).toContain("data-umami-event-target_id"); expect(src).toContain("data-umami-event-target_id");
expect(src).toContain("data-umami-event-domain"); expect(src).toContain("data-umami-event-domain");
}); });
it("instruments modal CTAs with correct attributes", async () => {
const src = await read("src/components/MediaModal.astro");
expect(src).toContain('"cta_click"');
expect(src).toContain('"data-umami-event-target_id"');
expect(src).toContain('"data-umami-event-placement", "media_modal"');
expect(src).toContain('"data-umami-event-platform"');
});
}); });