Compare commits
38 Commits
c773affbc8
...
ui-fixes
| Author | SHA1 | Date | |
|---|---|---|---|
| 847bb4558f | |||
| cd38afc013 | |||
| 67290567fb | |||
| da5263cd73 | |||
| d8b46cc6ca | |||
| 4c481e0dc1 | |||
| 63db318ce0 | |||
| d0dd9a0709 | |||
| dda37a4969 | |||
| 2b4c8a79e3 | |||
| 65b51d573a | |||
| fd3ebc6115 | |||
| a6e40f8b54 | |||
| 439b886a1b | |||
| 07d8787972 | |||
| 26a8c97841 | |||
| f50a828535 | |||
| 70710239c7 | |||
| 6cb4d55241 | |||
| 9fee6c8af7 | |||
| daac2eec20 | |||
| 57ad560b01 | |||
| 5d07e57256 | |||
| ac3de3e142 | |||
| 7bd51837de | |||
| b61146b145 | |||
| 7cb72b2746 | |||
| 35afd9208f | |||
| e2ef436d34 | |||
| 1d3d68df7b | |||
| 12e7eae95a | |||
| 8f1c0746a5 | |||
| c21614020a | |||
| 3b0b97f139 | |||
| 03df2b3a6c | |||
| b63c62a732 | |||
| c1ab51a149 | |||
| f056e67eae |
16
.claude/settings.local.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__plugin_chrome-devtools-mcp_chrome-devtools__navigate_page",
|
||||
"mcp__plugin_chrome-devtools-mcp_chrome-devtools__performance_start_trace",
|
||||
"mcp__plugin_chrome-devtools-mcp_chrome-devtools__performance_analyze_insight",
|
||||
"mcp__plugin_chrome-devtools-mcp_chrome-devtools__evaluate_script",
|
||||
"mcp__plugin_chrome-devtools-mcp_chrome-devtools__emulate",
|
||||
"mcp__plugin_chrome-devtools-mcp_chrome-devtools__lighthouse_audit",
|
||||
"Bash(node -e ':*)",
|
||||
"Bash(node:*)",
|
||||
"Bash(npx lighthouse:*)",
|
||||
"mcp__plugin_chrome-devtools-mcp_chrome-devtools__list_network_requests"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,7 @@
|
||||
**/dist
|
||||
**/.DS_Store
|
||||
|
||||
# Local secrets
|
||||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
|
||||
7
.github/workflows/ci.yml
vendored
@@ -23,3 +23,10 @@ jobs:
|
||||
- run: npm run format:check
|
||||
- run: npm test
|
||||
- run: npm run build
|
||||
- run: npm run verify:lighthouse
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: lighthouse-reports
|
||||
path: site/lighthouse-reports
|
||||
if-no-files-found: warn
|
||||
|
||||
72
.github/workflows/publish-image.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
name: publish-image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Rebuild periodically so content sources can be refreshed even without code changes.
|
||||
- cron: "0 9 * * *"
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
cache-dependency-path: site/package-lock.json
|
||||
|
||||
- name: Install + Fetch Content + Build Site
|
||||
working-directory: site
|
||||
env:
|
||||
YOUTUBE_CHANNEL_ID: ${{ secrets.YOUTUBE_CHANNEL_ID }}
|
||||
YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }}
|
||||
PODCAST_RSS_URL: ${{ secrets.PODCAST_RSS_URL }}
|
||||
WORDPRESS_BASE_URL: ${{ secrets.WORDPRESS_BASE_URL }}
|
||||
WORDPRESS_USERNAME: ${{ secrets.WORDPRESS_USERNAME }}
|
||||
WORDPRESS_APP_PASSWORD: ${{ secrets.WORDPRESS_APP_PASSWORD }}
|
||||
REDIS_URL: ${{ secrets.REDIS_URL }}
|
||||
run: |
|
||||
npm ci
|
||||
npm run fetch-content
|
||||
npm run build
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/metadata-action@v5
|
||||
id: meta
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=sha,format=short,prefix=sha-
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
BUILD_SHA=${{ github.sha }}
|
||||
BUILD_DATE=${{ github.run_started_at }}
|
||||
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 }}
|
||||
43
AGENTS.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
This repository is centered on the Astro app in `site/`.
|
||||
- `site/src/`: pages, layouts, components, and library code (`lib/ingest`, `lib/cache`, `lib/content`).
|
||||
- `site/tests/`: Vitest suites and fixtures for ingestion, tracking attributes, and UI behavior.
|
||||
- `site/content/`: curated inputs and build cache (`content/cache/content.json`).
|
||||
- `deploy/`: production Docker and nginx runbooks/config.
|
||||
- `openspec/`: active and archived feature specs; use these as behavior references.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
Run commands from repo root with `npm -C site run <script>`.
|
||||
- `npm -C site run dev`: start local Astro dev server (`http://localhost:4321`).
|
||||
- `npm -C site run fetch-content`: refresh aggregated source content before local validation.
|
||||
- `npm -C site run build`: build static output.
|
||||
- `npm -C site run preview`: preview production build locally.
|
||||
- `npm -C site run test`: run Vitest test suite.
|
||||
- `npm -C site run typecheck`: run Astro/TypeScript checks.
|
||||
- `npm -C site run format:check`: verify Prettier formatting.
|
||||
- `npm -C site run verify:lighthouse`: run Lighthouse quality gate assertions.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- TypeScript strict mode is enabled via `astro/tsconfigs/strict`.
|
||||
- Use Prettier (`site/prettier.config.cjs`) for formatting; run `npm -C site run format`.
|
||||
- Use 2-space indentation in JS/TS/Astro/CSS.
|
||||
- Component/layout files use PascalCase (for example `ContentCard.astro`).
|
||||
- Utility modules use lowercase/kebab-case or domain folders (for example `lib/ingest/youtube.ts`).
|
||||
|
||||
## Testing Guidelines
|
||||
- Framework: Vitest (`npm -C site run test`).
|
||||
- Place tests in `site/tests/` with `*.test.ts` naming.
|
||||
- Add/adjust tests for every behavior change in ingestion, tracking attributes, routing, or layout logic.
|
||||
- For performance or accessibility-sensitive changes, run `verify:lighthouse` and document any deviations.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Prefer concise, imperative commit subjects that explain intent (for example: `fix mobile view issue`).
|
||||
- Include a clear body when context is non-obvious: constraints, rejected options, and verification performed.
|
||||
- PRs should include: summary, linked issue/spec, test evidence (`test`, `typecheck`, `build`), and screenshots for UI changes.
|
||||
|
||||
## Security & Configuration Tips
|
||||
- Copy `site/.env.example` to `.env`; never commit secrets.
|
||||
- Keep analytics and feature flags environment-driven (`PUBLIC_UMAMI_*`, `PUBLIC_ENABLE_SW`, `PUBLIC_ENABLE_NAV_HOVER_LINE`).
|
||||
- Treat `site/content/cache/content.json` as generated build input; refresh via scripts instead of manual edits.
|
||||
262
DIAGNOSIS_COMPLETE.md
Normal 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
@@ -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.
|
||||
|
||||
================================================================================
|
||||
36
Dockerfile
@@ -2,16 +2,48 @@ FROM node:24-alpine AS builder
|
||||
|
||||
WORKDIR /app/site
|
||||
|
||||
ARG PUBLIC_ENABLE_SW=true
|
||||
ENV PUBLIC_ENABLE_SW=$PUBLIC_ENABLE_SW
|
||||
ARG PUBLIC_ENABLE_NAV_HOVER_LINE=true
|
||||
ENV PUBLIC_ENABLE_NAV_HOVER_LINE=$PUBLIC_ENABLE_NAV_HOVER_LINE
|
||||
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 ./
|
||||
RUN npm ci
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
COPY site/ ./
|
||||
# Content is fetched before build (typically in CI) and committed into the build context at
|
||||
# `site/content/cache/content.json`. If env vars aren't configured, the fetch step gracefully
|
||||
# skips sources and/or uses last-known-good cache.
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
ARG BUILD_SHA=unknown
|
||||
ARG BUILD_DATE=unknown
|
||||
ARG BUILD_REF=unknown
|
||||
|
||||
LABEL org.opencontainers.image.title="fast-website"
|
||||
LABEL org.opencontainers.image.description="Lightweight, SEO-first static site packaged as an nginx image."
|
||||
LABEL org.opencontainers.image.revision=$BUILD_SHA
|
||||
LABEL org.opencontainers.image.created=$BUILD_DATE
|
||||
LABEL org.opencontainers.image.source=$BUILD_REF
|
||||
|
||||
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/site/dist/ /usr/share/nginx/html/
|
||||
|
||||
EXPOSE 80
|
||||
# Operator-friendly version visibility.
|
||||
RUN printf '{\n "sha": "%s",\n "builtAt": "%s",\n "ref": "%s"\n}\n' "$BUILD_SHA" "$BUILD_DATE" "$BUILD_REF" \
|
||||
> /usr/share/nginx/html/build.json
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
294
PRODUCTION_DIAGNOSIS.md
Normal 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
|
||||
|
||||
101
README.md
@@ -2,6 +2,84 @@
|
||||
|
||||
Lightweight, SEO-first website for SanthoshJ that aggregates YouTube + Instagram + podcast content and tracks conversion events via Umami.
|
||||
|
||||
## Specs (OpenSpec)
|
||||
|
||||
This repo uses OpenSpec (schema: `spec-driven`) to document and ship features.
|
||||
|
||||
- Active specs live in `openspec/specs/<spec-name>/spec.md`
|
||||
- Completed initiatives (proposal/design/tasks + delta specs) live in `openspec/changes/archive/<date>-<change-name>/`
|
||||
|
||||
Key public flags (documented in `site/.env.example`):
|
||||
|
||||
- `PUBLIC_SITE_URL`: canonical URL base
|
||||
- `PUBLIC_ENABLE_SW`: set to `"false"` to disable service worker registration
|
||||
- `PUBLIC_ENABLE_NAV_HOVER_LINE`: set to `"false"` to disable decorative hover-line styling
|
||||
|
||||
### Spec Index (Active)
|
||||
|
||||
Content + ingestion:
|
||||
|
||||
- `social-content-aggregation` (`openspec/specs/social-content-aggregation/spec.md`): normalize YouTube/Instagram/podcast items and refresh via cached ingestion
|
||||
- `homepage-content-modules` (`openspec/specs/homepage-content-modules/spec.md`): homepage module ordering, newest feed, high-performing videos, and Instagram omission when empty
|
||||
- `wordpress-content-source` (`openspec/specs/wordpress-content-source/spec.md`): fetch WordPress posts/pages/categories via `wp-json` and write to build cache
|
||||
- `blog-section-surface` (`openspec/specs/blog-section-surface/spec.md`): `/blog` surface (index/category/detail) + Umami-instrumented navigation
|
||||
- `seo-content-surface` (`openspec/specs/seo-content-surface/spec.md`): indexable pages, canonical URLs, sitemap + robots, and JSON-LD expectations
|
||||
|
||||
Analytics + tracking:
|
||||
|
||||
- `interaction-tracking-taxonomy` (`openspec/specs/interaction-tracking-taxonomy/spec.md`): required Umami event attributes, `target_id` namespaces, and modal interaction events
|
||||
- `analytics-umami` (`openspec/specs/analytics-umami/spec.md`): Umami enable/disable behavior + supported custom events
|
||||
- `conversion-ctas` (`openspec/specs/conversion-ctas/spec.md`): reusable CTA surface + UTM support + `cta_click` tracking
|
||||
|
||||
UI + UX shell:
|
||||
|
||||
- `wcag-responsive-ui` (`openspec/specs/wcag-responsive-ui/spec.md`): responsive nav shell, focus-visible baseline, reduced motion, and semantic element rules
|
||||
- `card-layout-system` (`openspec/specs/card-layout-system/spec.md`): standardized card information architecture + modal-trigger behavior for video/podcast cards
|
||||
- `media-modal` (`openspec/specs/media-modal/spec.md`): `<dialog>`-based media preview modal requirements (focus, playback stop, crawlable CTAs)
|
||||
- `image-lazy-loading` (`openspec/specs/image-lazy-loading/spec.md`): shimmer placeholders + fade-in + failure handling + reduced-motion behavior
|
||||
- `site-theming` (`openspec/specs/site-theming/spec.md`): `data-theme` application, defaults, and persistence rules (localStorage + cookie fallback)
|
||||
- `theme-switcher-notch` (`openspec/specs/theme-switcher-notch/spec.md`): floating theme notch placement, interaction, and accessibility requirements
|
||||
- `navbar-branding` (`openspec/specs/navbar-branding/spec.md`): header logo + centered brand layout
|
||||
- `nav-hover-line` (`openspec/specs/nav-hover-line/spec.md`): decorative hover-line treatment for header titles + key surface titles (flagged)
|
||||
|
||||
Performance + deployment:
|
||||
|
||||
- `service-worker-performance` (`openspec/specs/service-worker-performance/spec.md`): production SW registration + runtime caching + safe updates
|
||||
- `responsive-image-delivery` (`openspec/specs/responsive-image-delivery/spec.md`): explicit dimensions + deterministic image behavior for quality gates
|
||||
- `lighthouse-quality-gate` (`openspec/specs/lighthouse-quality-gate/spec.md`): deterministic Lighthouse runner + 100-score assertion
|
||||
- `docker-content-refresh` (`openspec/specs/docker-content-refresh/spec.md`): Docker-only host refresh/update workflow (no Node.js on server)
|
||||
- `cache-layer` (`openspec/specs/cache-layer/spec.md`): Redis-backed shared cache + TTL + manual cache clear
|
||||
|
||||
### Quality Gates
|
||||
|
||||
- Site build: `npm -C site run build`
|
||||
- Lighthouse gate docs: `site/docs/lighthouse.md` (scripts: `npm -C site run lighthouse:run`, `npm -C site run verify:lighthouse`)
|
||||
|
||||
### Completed Initiatives (Archived)
|
||||
|
||||
Each archived initiative includes `proposal.md`, `design.md`, `tasks.md`, and any delta specs used for that change.
|
||||
|
||||
| Change | Focus |
|
||||
|---|---|
|
||||
| `2026-02-10-dynamic-homepage-social-acquisition` | Initial SEO-first site: content aggregation, homepage modules, CTAs, and analytics |
|
||||
| `2026-02-10-better-tracking` | Site-wide click tracking taxonomy aligned to Umami |
|
||||
| `2026-02-10-custom-events-umami` | Expand Umami custom event coverage + standardize event properties |
|
||||
| `2026-02-10-blog-umami-fix` | Fix and verify blog surface Umami instrumentation |
|
||||
| `2026-02-10-better-cache` | Add Redis cache layer + TTL + manual clear; wire into ingestion |
|
||||
| `2026-02-10-card-layout` | Standardize card layout across surfaces |
|
||||
| `2026-02-10-lazy-loading` | Add shimmer placeholders and reduced-motion-safe image loading UX |
|
||||
| `2026-02-10-blogs-section` | Add WordPress-backed blog section + routes + secondary nav |
|
||||
| `2026-02-10-hide-ig-if-no-data` | Omit Instagram module when dataset is empty |
|
||||
| `2026-02-10-reduce-bounce-rate` | Add in-page media modal previews for video/podcast cards |
|
||||
| `2026-02-10-service-workers` | Add service worker caching for repeat-visit performance |
|
||||
| `2026-02-10-deploy-without-node` | Docker-only host refresh/update workflow |
|
||||
| `2026-02-10-fix-sub-pages` | Fix static serving so `/videos`, `/podcast`, `/about` do not 404 |
|
||||
| `2026-02-10-wcag-responsive` | WCAG baseline + responsive nav shell + typography/background fixes |
|
||||
| `2026-02-10-lighthouse-fixes` | Lighthouse cleanup + deterministic 100/100/100/100 gate |
|
||||
| `2026-02-11-dch-theming` | Dark/light/high-contrast themes + theme notch UI |
|
||||
| `2026-02-11-remember-theme` | Persist theme across visits + emit theme switch tracking |
|
||||
| `2026-02-11-final-touches` | Header branding polish + hover-line treatment behind env flag |
|
||||
|
||||
## Local Setup
|
||||
|
||||
```bash
|
||||
@@ -86,7 +164,9 @@ Instrumentation checklist:
|
||||
|
||||
## Deployment (Linode + Docker)
|
||||
|
||||
Build and run:
|
||||
The production host is intentionally minimal and only needs Docker (no Node.js on the server).
|
||||
|
||||
### Local Docker
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
@@ -95,24 +175,29 @@ docker compose up -d
|
||||
|
||||
The container serves the static output on port `8080` (map or proxy as needed).
|
||||
|
||||
### Refreshing Content (Manual)
|
||||
### Production (Docker-Only Host)
|
||||
|
||||
Content is fetched at build time into `site/content/cache/content.json`.
|
||||
In production, CI builds and publishes a Docker image (nginx serving the static output). The server updates by pulling that image and restarting the service.
|
||||
|
||||
On the Linode host:
|
||||
Runbook: `deploy/runbook.md`.
|
||||
|
||||
### Refreshing Content (Manual, Docker-Only)
|
||||
|
||||
Content is fetched at build time into `site/content/cache/content.json` (typically in CI), then packaged into the image.
|
||||
|
||||
On the server host:
|
||||
|
||||
```bash
|
||||
./scripts/refresh.sh
|
||||
```
|
||||
|
||||
This:
|
||||
1. Runs `npm run fetch-content` in `site/`
|
||||
2. Rebuilds the Docker image
|
||||
3. Restarts the container
|
||||
1. Pulls the latest published image
|
||||
2. Restarts the service (no build on the host)
|
||||
|
||||
### Refreshing Content (Scheduled)
|
||||
|
||||
Install a daily cron using `deploy/cron.example` as a starting point.
|
||||
|
||||
Rollback:
|
||||
- Re-run `docker compose up -d` with a previously built image/tag, or restore the last known-good repo state and rerun `scripts/refresh.sh`.
|
||||
- Re-deploy a known-good image tag/digest (see `deploy/runbook.md`).
|
||||
|
||||
BIN
__pycache__/graphify_build.cpython-313.pyc
Normal file
BIN
blog-screenshot.png
Normal file
|
After Width: | Height: | Size: 971 KiB |
BIN
csp-validation-final.png
Normal file
|
After Width: | Height: | Size: 826 KiB |
6
deploy/docker-compose.prod.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
services:
|
||||
web:
|
||||
image: ${WEB_IMAGE:?Set WEB_IMAGE to the published image tag or digest}
|
||||
ports:
|
||||
- "8080:80"
|
||||
|
||||
@@ -2,13 +2,48 @@ server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/javascript application/javascript application/json application/xml application/rss+xml image/svg+xml;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Custom error pages
|
||||
error_page 404 /404.html;
|
||||
error_page 500 502 503 504 /500.html;
|
||||
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.instagram.com https://*.instagram.com https://cloud.umami.is https://*.umami.is https://wa.santhoshj.com; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' https: data: blob:; media-src 'self' https://anchor.fm https://*.anchor.fm https://d3ctxlq1ktw2nl.cloudfront.net; connect-src 'self' https://cloud.umami.is https://*.umami.is https://wa.santhoshj.com; frame-src https://www.youtube.com https://open.spotify.com https://www.instagram.com https://*.instagram.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
|
||||
# Static assets
|
||||
location = /sw.js {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~* \.(?:avif|bmp|gif|ico|jpe?g|png|svg|webp|)$ {
|
||||
expires 180d;
|
||||
add_header Cache-Control "public, max-age=15552000" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Ensure error pages are served without redirect
|
||||
location = /404.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
location = /500.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
location / {
|
||||
# Serve directory index pages without requiring a trailing slash.
|
||||
# This fixes /videos (and similar) resolving to /videos/index.html.
|
||||
try_files $uri $uri/index.html $uri/ =404;
|
||||
}
|
||||
}
|
||||
}
|
||||
86
deploy/runbook.md
Normal file
@@ -0,0 +1,86 @@
|
||||
## Deploy Runbook (Docker-Only Host)
|
||||
|
||||
This runbook is for a minimal production host where **Docker is installed** and **Node.js is not**.
|
||||
|
||||
The deployment model is:
|
||||
- CI builds and publishes a Docker image containing the built static site
|
||||
- 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
|
||||
|
||||
- Docker + Docker Compose plugin available on the host
|
||||
- Registry access (e.g., logged in to GHCR if the image is private)
|
||||
- A `WEB_IMAGE` value pointing at the image to deploy (tag or digest)
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
export WEB_IMAGE=ghcr.io/<owner>/<repo>:latest
|
||||
```
|
||||
|
||||
### First-Time Start
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Refresh (Pull + Restart)
|
||||
|
||||
Pull first (safe; does not affect the running container):
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.prod.yml pull
|
||||
```
|
||||
|
||||
Then restart the service on the newly pulled image:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.prod.yml up -d --no-build
|
||||
```
|
||||
|
||||
### Verify Deployed Version
|
||||
|
||||
1. Check the container's image reference (tag/digest):
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.prod.yml ps
|
||||
docker inspect --format '{{.Image}} {{.Config.Image}}' <container-id>
|
||||
```
|
||||
|
||||
2. Check build metadata served by the site:
|
||||
|
||||
```bash
|
||||
curl -fsS http://localhost:8080/build.json
|
||||
```
|
||||
|
||||
### Rollback
|
||||
|
||||
Re-deploy a known-good version by pinning a previous tag or digest:
|
||||
|
||||
```bash
|
||||
export WEB_IMAGE=ghcr.io/<owner>/<repo>:<known-good-tag>
|
||||
docker compose -f deploy/docker-compose.prod.yml up -d --no-build
|
||||
```
|
||||
|
||||
Recommended: record the image digest for each release (`docker inspect <image> --format '{{.Id}}'`), and use a digest pin for true immutability.
|
||||
|
||||
### Failure Mode Validation (Pull Failure)
|
||||
|
||||
If `docker compose pull` fails, **do not run** the restart step. The running site will continue serving the existing container.
|
||||
|
||||
To simulate a pull failure safely:
|
||||
|
||||
```bash
|
||||
export WEB_IMAGE=ghcr.io/<owner>/<repo>:this-tag-does-not-exist
|
||||
docker compose -f deploy/docker-compose.prod.yml pull
|
||||
```
|
||||
|
||||
The pull should fail, but the current service should still be running:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.prod.yml ps
|
||||
curl -fsS http://localhost:8080/ > /dev/null
|
||||
```
|
||||
@@ -1,8 +1,37 @@
|
||||
services:
|
||||
web:
|
||||
image: ${WEB_IMAGE:-fast-website:local}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
# Build-time toggle for service worker registration in the generated static HTML.
|
||||
PUBLIC_ENABLE_SW: ${PUBLIC_ENABLE_SW:-true}
|
||||
PUBLIC_ENABLE_NAV_HOVER_LINE: ${PUBLIC_ENABLE_NAV_HOVER_LINE:-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:
|
||||
- "8080:80"
|
||||
networks:
|
||||
- fast-website-network
|
||||
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
# Use 6380 to avoid colliding with any locally installed Redis on 6379.
|
||||
- "6380:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
networks:
|
||||
- fast-website-network
|
||||
|
||||
networks:
|
||||
fast-website-network:
|
||||
driver: bridge
|
||||
|
||||
425
graphify-out/GRAPH_REPORT.md
Normal file
@@ -0,0 +1,425 @@
|
||||
# Graph Report - . (2026-04-19)
|
||||
|
||||
## Corpus Check
|
||||
- 177 files · ~197,362 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 235 nodes · 221 edges · 57 communities detected
|
||||
- Extraction: 89% EXTRACTED · 11% INFERRED · 0% AMBIGUOUS · INFERRED: 25 edges (avg confidence: 0.76)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
- [[_COMMUNITY_Layout Persistence Branding|Layout Persistence Branding]]
|
||||
- [[_COMMUNITY_Concept Modal Youtube|Concept Modal Youtube]]
|
||||
- [[_COMMUNITY_Main Getingestconfigfromenv Getpublicconfig|Main Getingestconfigfromenv Getpublicconfig]]
|
||||
- [[_COMMUNITY_Wordpress Decodeentities Fetchallpages|Wordpress Decodeentities Fetchallpages]]
|
||||
- [[_COMMUNITY_Cache Main Createcachefromenv|Cache Main Createcachefromenv]]
|
||||
- [[_COMMUNITY_Highperformingyoutubevideos Instagramposts Newestitems|Highperformingyoutubevideos Instagramposts Newestitems]]
|
||||
- [[_COMMUNITY_Compute Sleep Cachedcompute|Compute Sleep Cachedcompute]]
|
||||
- [[_COMMUNITY_Cache Slug Build|Cache Slug Build]]
|
||||
- [[_COMMUNITY_Image Placeholder Load|Image Placeholder Load]]
|
||||
- [[_COMMUNITY_Website Repository Guidelines|Website Repository Guidelines]]
|
||||
- [[_COMMUNITY_Youtube Fetchyoutubeviaapi Fetchyoutubeviarss|Youtube Fetchyoutubeviaapi Fetchyoutubeviarss]]
|
||||
- [[_COMMUNITY_Cacheputsafe Isget Isimagerequest|Cacheputsafe Isget Isimagerequest]]
|
||||
- [[_COMMUNITY_Production Notch Styling|Production Notch Styling]]
|
||||
- [[_COMMUNITY_Astro Blogpostcard Layout|Astro Blogpostcard Layout]]
|
||||
- [[_COMMUNITY_Getcachepath Readcontentcache Verify|Getcachepath Readcontentcache Verify]]
|
||||
- [[_COMMUNITY_Fetchpodcastrss Normalizepodcastfeeditems Striphtml|Fetchpodcastrss Normalizepodcastfeeditems Striphtml]]
|
||||
- [[_COMMUNITY_Target Data Umami|Target Data Umami]]
|
||||
- [[_COMMUNITY_Screenshot Review Santhoshj|Screenshot Review Santhoshj]]
|
||||
- [[_COMMUNITY_Tests Listed Files|Tests Listed Files]]
|
||||
- [[_COMMUNITY_Service Worker Critical|Service Worker Critical]]
|
||||
- [[_COMMUNITY_Withutm|Withutm]]
|
||||
- [[_COMMUNITY_Creatememorycache Memory Cache|Creatememorycache Memory Cache]]
|
||||
- [[_COMMUNITY_Readfeaturedvideoids Curation|Readfeaturedvideoids Curation]]
|
||||
- [[_COMMUNITY_Read Umami Attributes|Read Umami Attributes]]
|
||||
- [[_COMMUNITY_Read Layout Test|Read Layout Test]]
|
||||
- [[_COMMUNITY_Read Title Type|Read Title Type]]
|
||||
- [[_COMMUNITY_Umami Attributes Test|Umami Attributes Test]]
|
||||
- [[_COMMUNITY_Wcag Responsive Shell|Wcag Responsive Shell]]
|
||||
- [[_COMMUNITY_Normalized Schema Wordpress|Normalized Schema Wordpress]]
|
||||
- [[_COMMUNITY_Passwords Rationale Revocable|Passwords Rationale Revocable]]
|
||||
- [[_COMMUNITY_Astro Config|Astro Config]]
|
||||
- [[_COMMUNITY_Community 31|Community 31]]
|
||||
- [[_COMMUNITY_Links|Links]]
|
||||
- [[_COMMUNITY_Types|Types]]
|
||||
- [[_COMMUNITY_Types|Types]]
|
||||
- [[_COMMUNITY_Test|Test]]
|
||||
- [[_COMMUNITY_Ingest Test|Ingest Test]]
|
||||
- [[_COMMUNITY_Desktop Header Screenshot|Desktop Header Screenshot]]
|
||||
- [[_COMMUNITY_Mobile Header Screenshot|Mobile Header Screenshot]]
|
||||
- [[_COMMUNITY_Surface|Surface]]
|
||||
- [[_COMMUNITY_Rationale Structure Keeps|Rationale Structure Keeps]]
|
||||
- [[_COMMUNITY_Contentitem Summary Optional|Contentitem Summary Optional]]
|
||||
- [[_COMMUNITY_Standardcard Astro|Standardcard Astro]]
|
||||
- [[_COMMUNITY_Post Slug Astro|Post Slug Astro]]
|
||||
- [[_COMMUNITY_Slug Astro|Slug Astro]]
|
||||
- [[_COMMUNITY_Point Quality Gate|Point Quality Gate]]
|
||||
- [[_COMMUNITY_Bounce Rate Reduction|Bounce Rate Reduction]]
|
||||
- [[_COMMUNITY_Preview Modal Instead|Preview Modal Instead]]
|
||||
- [[_COMMUNITY_Umami Preview Event|Umami Preview Event]]
|
||||
- [[_COMMUNITY_Modal Tracking Click|Modal Tracking Click]]
|
||||
- [[_COMMUNITY_Precedence Localstorage Cookie|Precedence Localstorage Cookie]]
|
||||
- [[_COMMUNITY_Cookie Fallback Long|Cookie Fallback Long]]
|
||||
- [[_COMMUNITY_Switch Event Automatic|Switch Event Automatic]]
|
||||
- [[_COMMUNITY_Surface|Surface]]
|
||||
- [[_COMMUNITY_Social Aggregation|Social Aggregation]]
|
||||
- [[_COMMUNITY_Homepage Modules|Homepage Modules]]
|
||||
- [[_COMMUNITY_Conversion Ctas|Conversion Ctas]]
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `2026-02-10 lighthouse fixes change` - 13 edges
|
||||
2. `main()` - 10 edges
|
||||
3. `createCacheFromEnv()` - 8 edges
|
||||
4. `media-modal capability` - 7 edges
|
||||
5. `run()` - 6 edges
|
||||
6. `normalizeWordpressPost()` - 6 edges
|
||||
7. `fast-website` - 6 edges
|
||||
8. `Three-theme system (dark/light/high-contrast)` - 6 edges
|
||||
9. `log()` - 5 edges
|
||||
10. `normalizeWordpressPage()` - 5 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `Favicon SVG stylized S mark` --supports_branding--> `SEO-first content aggregation website` [INFERRED]
|
||||
site/public/favicon.svg → README.md
|
||||
- `run()` --calls--> `log()` [INFERRED]
|
||||
site\scripts\run-lighthouse.ts → site\scripts\fetch-content.ts
|
||||
- `createCacheFromEnv()` --calls--> `log()` [INFERRED]
|
||||
site\src\lib\cache\index.ts → site\scripts\fetch-content.ts
|
||||
- `main()` --calls--> `getIngestConfigFromEnv()` [INFERRED]
|
||||
site\scripts\fetch-content.ts → site\src\lib\config.ts
|
||||
- `main()` --calls--> `createCacheFromEnv()` [INFERRED]
|
||||
site\scripts\fetch-content.ts → site\src\lib\cache\index.ts
|
||||
|
||||
## Hyperedges (group relationships)
|
||||
- **Theme notch incident bundle** — doc_diag_complete, doc_diag_summary, doc_prod_diagnosis, issue_theme_notch_production_unstyled, cause_stale_production_css, fix_rebuild_redeploy_docker [INFERRED 0.90]
|
||||
- **Lighthouse gate contract** — capability_lighthouse_quality_gate, concept_lighthouse_gate_100, concept_theme_system, concept_service_worker_caching, concept_layout_shift_prevention [EXTRACTED 0.90]
|
||||
- **Media modal implementation bundle** — capability_media_modal, concept_native_dialog_modal, concept_iframe_src_reset, concept_cards_as_buttons, concept_data_attributes_modal, concept_modal_embed_urls, concept_wcag_modal_accessibility, concept_umami_media_preview, concept_modal_cta_tracking [EXTRACTED 0.90]
|
||||
- **Theme persistence bundle** — change_remember_theme, concept_theme_persistence, concept_theme_precedence_order, concept_site_theme_cookie, concept_theme_switch_event, concept_theme_switch_payload, concept_no_restore_event [EXTRACTED 0.90]
|
||||
|
||||
## Communities
|
||||
|
||||
### Community 0 - "Layout Persistence Branding"
|
||||
Cohesion: 0.08
|
||||
Nodes (25): lighthouse-quality-gate capability, Accessible semantic controls, deterministic lighthouse gate, Layout shift prevention, prefers-reduced-motion handling, theme persistence across visits, Theme persistence with localStorage/cookie fallback, theme_switch umami event (+17 more)
|
||||
|
||||
### Community 1 - "Concept Modal Youtube"
|
||||
Cohesion: 0.12
|
||||
Nodes (18): media-modal capability, 2026-02-10 lighthouse fixes change, youtube iframe api endpoint, video/podcast cards as button triggers, concept crawlable anchors, concept csp header alignment, concept dark theme contrast tokens, data-* attribute flow from cards to modal (+10 more)
|
||||
|
||||
### Community 2 - "Main Getingestconfigfromenv Getpublicconfig"
|
||||
Cohesion: 0.18
|
||||
Nodes (11): getIngestConfigFromEnv(), dedupe(), log(), main(), normalizeSpotifyEpisodeUrl(), readPodcastSpotifyOverrideMap(), writeAtomic(), readInstagramEmbedPosts() (+3 more)
|
||||
|
||||
### Community 3 - "Wordpress Decodeentities Fetchallpages"
|
||||
Cohesion: 0.27
|
||||
Nodes (10): decodeEntities(), fetchWordpressContent(), getAuthHeaders(), mapCategoryIds(), mapFeaturedImageUrl(), normalizeWordpressCategory(), normalizeWordpressPage(), normalizeWordpressPost() (+2 more)
|
||||
|
||||
### Community 4 - "Cache Main Createcachefromenv"
|
||||
Cohesion: 0.21
|
||||
Nodes (7): log(), main(), createCacheFromEnv(), createNoopCache(), createRedisCache(), resolveDefaultTtlSecondsFromEnv(), resolveRedisUrlFromEnv()
|
||||
|
||||
### Community 5 - "Highperformingyoutubevideos Instagramposts Newestitems"
|
||||
Cohesion: 0.2
|
||||
Nodes (4): highPerformingYoutubeVideos(), wordpressPosts(), wordpressPostsByCategorySlug(), youtubeVideos()
|
||||
|
||||
### Community 6 - "Compute Sleep Cachedcompute"
|
||||
Cohesion: 0.25
|
||||
Nodes (7): compute(), sleep(), cachedCompute(), getArg(), hasFlag(), run(), startPreviewServerIfNeeded()
|
||||
|
||||
### Community 7 - "Cache Slug Build"
|
||||
Cohesion: 0.18
|
||||
Nodes (11): blog-section-surface, build-time ingestion into content cache, site/content/cache/content.json, site/scripts/fetch-content.ts, Rationale: build-time cache keeps site fast and crawlable, /blog/category/<slug> route, /blog route, /blog/page/<slug> route (+3 more)
|
||||
|
||||
### Community 8 - "Image Placeholder Load"
|
||||
Cohesion: 0.2
|
||||
Nodes (10): BaseLayout inline image load script, static placeholder on image error, fade-in on image load, image-lazy-loading, .img-error, .img-loading, .img-shimmer-wrap, no CLS from placeholder (+2 more)
|
||||
|
||||
### Community 9 - "Website Repository Guidelines"
|
||||
Cohesion: 0.22
|
||||
Nodes (9): Repository Guidelines, SVG uses prefers-color-scheme dark fill override, Favicon SVG stylized S mark, lighthouse-quality-gate, nav-hover-line, theme-switcher-notch, OpenSpec schema spec-driven, fast-website (+1 more)
|
||||
|
||||
### Community 10 - "Youtube Fetchyoutubeviaapi Fetchyoutubeviarss"
|
||||
Cohesion: 0.36
|
||||
Nodes (6): fetchYoutubeViaApi(), fetchYoutubeViaRss(), normalizeYoutubeApiVideos(), normalizeYoutubeRssFeedItems(), stripHtml(), truncate()
|
||||
|
||||
### Community 11 - "Cacheputsafe Isget Isimagerequest"
|
||||
Cohesion: 0.33
|
||||
Nodes (0):
|
||||
|
||||
### Community 12 - "Production Notch Styling"
|
||||
Cohesion: 0.33
|
||||
Nodes (6): Stale production CSS missing theme-notch rules, Production Theme Notch Styling Issue - Complete Diagnostic Report, Production Theme Notch Styling Issue - Executive Summary, Production Theme Notch Styling Issue - Diagnostic Report, Rebuild and redeploy Docker image, Theme notch unstyled on production
|
||||
|
||||
### Community 13 - "Astro Blogpostcard Layout"
|
||||
Cohesion: 0.33
|
||||
Nodes (4): card-layout-system, views shown only when available, shared Card component, standard footer/meta row
|
||||
|
||||
### Community 14 - "Getcachepath Readcontentcache Verify"
|
||||
Cohesion: 0.5
|
||||
Nodes (3): getCachePath(), readContentCache(), main()
|
||||
|
||||
### Community 15 - "Fetchpodcastrss Normalizepodcastfeeditems Striphtml"
|
||||
Cohesion: 0.6
|
||||
Nodes (4): fetchPodcastRss(), normalizePodcastFeedItems(), stripHtml(), truncate()
|
||||
|
||||
### Community 16 - "Target Data Umami"
|
||||
Cohesion: 0.5
|
||||
Nodes (4): data-umami-event attributes, placement, target_id, target_url
|
||||
|
||||
### Community 17 - "Screenshot Review Santhoshj"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): Performance Review — santhoshj.com, Blog page screenshot, Homepage screenshot
|
||||
|
||||
### Community 18 - "Tests Listed Files"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): Listed spec files missing in current workspace, site/tests/content.spec.ts, site/tests/yaml.spec.ts
|
||||
|
||||
### Community 19 - "Service Worker Critical"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): Critical-asset cache busting, Service worker caching lifecycle, Service worker performance spec
|
||||
|
||||
### Community 20 - "Withutm"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 21 - "Creatememorycache Memory Cache"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 22 - "Readfeaturedvideoids Curation"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 23 - "Read Umami Attributes"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 24 - "Read Layout Test"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 25 - "Read Title Type"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 26 - "Umami Attributes Test"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 27 - "Wcag Responsive Shell"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 28 - "Normalized Schema Wordpress"
|
||||
Cohesion: 1.0
|
||||
Nodes (2): normalized wordpress internal schema, Rationale: normalized schema keeps UI simple and consistent
|
||||
|
||||
### Community 29 - "Passwords Rationale Revocable"
|
||||
Cohesion: 1.0
|
||||
Nodes (2): Rationale: app passwords are revocable and safer operationally, WordPress application passwords
|
||||
|
||||
### Community 30 - "Astro Config"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 31 - "Community 31"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 32 - "Links"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 33 - "Types"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 34 - "Types"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 35 - "Test"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 36 - "Ingest Test"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 37 - "Desktop Header Screenshot"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Desktop header screenshot
|
||||
|
||||
### Community 38 - "Mobile Header Screenshot"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Mobile header screenshot
|
||||
|
||||
### Community 39 - "Surface"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): seo-content-surface
|
||||
|
||||
### Community 40 - "Rationale Structure Keeps"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Rationale: route structure keeps URLs clear and stable
|
||||
|
||||
### Community 41 - "Contentitem Summary Optional"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): ContentItem.summary optional field
|
||||
|
||||
### Community 42 - "Standardcard Astro"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 43 - "Post Slug Astro"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): blog/post/[slug].astro
|
||||
|
||||
### Community 44 - "Slug Astro"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): blog/page/[slug].astro
|
||||
|
||||
### Community 45 - "Point Quality Gate"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Lighthouse 100-point quality gate
|
||||
|
||||
### Community 46 - "Bounce Rate Reduction"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Bounce rate reduction intent
|
||||
|
||||
### Community 47 - "Preview Modal Instead"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): on-site media preview modal instead of outbound navigation
|
||||
|
||||
### Community 48 - "Umami Preview Event"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): umami media_preview event taxonomy
|
||||
|
||||
### Community 49 - "Modal Tracking Click"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): modal CTA tracking via cta_click taxonomy
|
||||
|
||||
### Community 50 - "Precedence Localstorage Cookie"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): theme precedence localStorage -> cookie -> environment signals
|
||||
|
||||
### Community 51 - "Cookie Fallback Long"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): site_theme cookie fallback with long TTL
|
||||
|
||||
### Community 52 - "Switch Event Automatic"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): no theme_switch event on automatic restoration
|
||||
|
||||
### Community 53 - "Surface"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): SEO content surface spec
|
||||
|
||||
### Community 54 - "Social Aggregation"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Social content aggregation spec
|
||||
|
||||
### Community 55 - "Homepage Modules"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Homepage content modules spec
|
||||
|
||||
### Community 56 - "Conversion Ctas"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Conversion CTAs spec
|
||||
|
||||
## Knowledge Gaps
|
||||
- **78 isolated node(s):** `Repository Guidelines`, `OpenSpec schema spec-driven`, `theme-switcher-notch`, `nav-hover-line`, `lighthouse-quality-gate` (+73 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **Thin community `Withutm`** (2 nodes): `url.ts`, `withUtm()`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Creatememorycache Memory Cache`** (2 nodes): `createMemoryCache()`, `memory-cache.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Readfeaturedvideoids Curation`** (2 nodes): `readFeaturedVideoIds()`, `curation.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Read Umami Attributes`** (2 nodes): `read()`, `blog-umami-attributes.test.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Read Layout Test`** (2 nodes): `read()`, `card-layout.test.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Read Title Type`** (2 nodes): `read()`, `content-title-type-attributes.test.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Umami Attributes Test`** (2 nodes): `umami-attributes.test.ts`, `read()`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Wcag Responsive Shell`** (2 nodes): `wcag-responsive-shell.test.ts`, `read()`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Normalized Schema Wordpress`** (2 nodes): `normalized wordpress internal schema`, `Rationale: normalized schema keeps UI simple and consistent`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Passwords Rationale Revocable`** (2 nodes): `Rationale: app passwords are revocable and safer operationally`, `WordPress application passwords`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Astro Config`** (1 nodes): `astro.config.mjs`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 31`** (1 nodes): `env.d.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Links`** (1 nodes): `links.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Types`** (1 nodes): `types.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Types`** (1 nodes): `types.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Test`** (1 nodes): `blog-nav.test.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Ingest Test`** (1 nodes): `ingest.test.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Desktop Header Screenshot`** (1 nodes): `Desktop header screenshot`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Mobile Header Screenshot`** (1 nodes): `Mobile header screenshot`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Surface`** (1 nodes): `seo-content-surface`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Rationale Structure Keeps`** (1 nodes): `Rationale: route structure keeps URLs clear and stable`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Contentitem Summary Optional`** (1 nodes): `ContentItem.summary optional field`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Standardcard Astro`** (1 nodes): `StandardCard.astro`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Post Slug Astro`** (1 nodes): `blog/post/[slug].astro`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Slug Astro`** (1 nodes): `blog/page/[slug].astro`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Point Quality Gate`** (1 nodes): `Lighthouse 100-point quality gate`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Bounce Rate Reduction`** (1 nodes): `Bounce rate reduction intent`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Preview Modal Instead`** (1 nodes): `on-site media preview modal instead of outbound navigation`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Umami Preview Event`** (1 nodes): `umami media_preview event taxonomy`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Modal Tracking Click`** (1 nodes): `modal CTA tracking via cta_click taxonomy`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Precedence Localstorage Cookie`** (1 nodes): `theme precedence localStorage -> cookie -> environment signals`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Cookie Fallback Long`** (1 nodes): `site_theme cookie fallback with long TTL`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Switch Event Automatic`** (1 nodes): `no theme_switch event on automatic restoration`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Surface`** (1 nodes): `SEO content surface spec`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Social Aggregation`** (1 nodes): `Social content aggregation spec`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Homepage Modules`** (1 nodes): `Homepage content modules spec`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Conversion Ctas`** (1 nodes): `Conversion CTAs spec`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `2026-02-10 lighthouse fixes change` connect `Concept Modal Youtube` to `Layout Persistence Branding`?**
|
||||
_High betweenness centrality (0.020) - this node is a cross-community bridge._
|
||||
- **Why does `prefers-reduced-motion handling` connect `Layout Persistence Branding` to `Concept Modal Youtube`?**
|
||||
_High betweenness centrality (0.017) - this node is a cross-community bridge._
|
||||
- **Why does `main()` connect `Main Getingestconfigfromenv Getpublicconfig` to `Cache Main Createcachefromenv`, `Compute Sleep Cachedcompute`?**
|
||||
_High betweenness centrality (0.012) - this node is a cross-community bridge._
|
||||
- **Are the 4 inferred relationships involving `main()` (e.g. with `getIngestConfigFromEnv()` and `createCacheFromEnv()`) actually correct?**
|
||||
_`main()` has 4 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 7 inferred relationships involving `createCacheFromEnv()` (e.g. with `main()` and `main()`) actually correct?**
|
||||
_`createCacheFromEnv()` has 7 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 2 inferred relationships involving `run()` (e.g. with `sleep()` and `log()`) actually correct?**
|
||||
_`run()` has 2 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **What connects `Repository Guidelines`, `OpenSpec schema spec-driven`, `theme-switcher-notch` to the rest of the system?**
|
||||
_78 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
12
graphify-out/cost.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"runs": [
|
||||
{
|
||||
"date": "2026-04-19T00:00:00Z",
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"files": 177
|
||||
}
|
||||
],
|
||||
"total_input_tokens": 0,
|
||||
"total_output_tokens": 0
|
||||
}
|
||||
257
graphify-out/graph.html
Normal file
4697
graphify-out/graph.json
Normal file
179
graphify-out/manifest.json
Normal file
@@ -0,0 +1,179 @@
|
||||
{
|
||||
"site\\astro.config.mjs": 1770696052.5299394,
|
||||
"site\\public\\sw.js": 1770778835.0382543,
|
||||
"site\\scripts\\cache-clear.ts": 1770704069.523524,
|
||||
"site\\scripts\\fetch-content.ts": 1770759379.7095494,
|
||||
"site\\scripts\\run-lighthouse.ts": 1770779378.6530933,
|
||||
"site\\scripts\\verify-blog-build.ts": 1770703147.2500439,
|
||||
"site\\scripts\\verify-umami-in-dist.ts": 1770722100.1253102,
|
||||
"site\\src\\env.d.ts": 1770781865.8927536,
|
||||
"site\\src\\lib\\config.ts": 1770781915.8184347,
|
||||
"site\\src\\lib\\links.ts": 1770760446.5389628,
|
||||
"site\\src\\lib\\url.ts": 1770696052.7901123,
|
||||
"site\\src\\lib\\cache\\index.ts": 1770704027.8143616,
|
||||
"site\\src\\lib\\cache\\memoize.ts": 1770704096.3146253,
|
||||
"site\\src\\lib\\cache\\memory-cache.ts": 1770704090.5106878,
|
||||
"site\\src\\lib\\cache\\noop-cache.ts": 1770704019.2556648,
|
||||
"site\\src\\lib\\cache\\redis-cache.ts": 1770704014.1144633,
|
||||
"site\\src\\lib\\content\\cache.ts": 1770702913.9393668,
|
||||
"site\\src\\lib\\content\\curation.ts": 1770696052.7545974,
|
||||
"site\\src\\lib\\content\\selectors.ts": 1770702925.4313648,
|
||||
"site\\src\\lib\\content\\types.ts": 1770759237.107753,
|
||||
"site\\src\\lib\\ingest\\instagram.ts": 1770696052.7691603,
|
||||
"site\\src\\lib\\ingest\\podcast.ts": 1770759246.3350432,
|
||||
"site\\src\\lib\\ingest\\types.ts": 1770696052.7765744,
|
||||
"site\\src\\lib\\ingest\\wordpress.ts": 1770702898.2534895,
|
||||
"site\\src\\lib\\ingest\\youtube.ts": 1770707408.2922978,
|
||||
"site\\tests\\blog-nav.test.ts": 1770703139.8473601,
|
||||
"site\\tests\\blog-umami-attributes.test.ts": 1770707507.5898702,
|
||||
"site\\tests\\cache-wrapper.test.ts": 1770704106.4231348,
|
||||
"site\\tests\\card-layout.test.ts": 1770778848.318161,
|
||||
"site\\tests\\content-title-type-attributes.test.ts": 1770707512.7382674,
|
||||
"site\\tests\\ingest.test.ts": 1770696052.869126,
|
||||
"site\\tests\\umami-attributes.test.ts": 1770757650.2056983,
|
||||
"site\\tests\\wcag-responsive-shell.test.ts": 1770778855.4394329,
|
||||
"AGENTS.md": 1776628237.9663427,
|
||||
"DIAGNOSIS_COMPLETE.md": 1770774350.9478524,
|
||||
"DIAGNOSIS_SUMMARY.txt": 1770774329.1455705,
|
||||
"PRODUCTION_DIAGNOSIS.md": 1770774313.8073833,
|
||||
"README.md": 1770783181.1709754,
|
||||
"review-notes.md": 1775892139.263028,
|
||||
"deploy\\runbook.md": 1770716901.5902822,
|
||||
"openspec\\changes\\archive\\2026-02-10-better-cache\\design.md": 1770703824.4651365,
|
||||
"openspec\\changes\\archive\\2026-02-10-better-cache\\proposal.md": 1770703732.19924,
|
||||
"openspec\\changes\\archive\\2026-02-10-better-cache\\tasks.md": 1770704138.6287496,
|
||||
"openspec\\changes\\archive\\2026-02-10-better-cache\\specs\\cache-layer\\spec.md": 1770703833.315094,
|
||||
"openspec\\changes\\archive\\2026-02-10-better-cache\\specs\\social-content-aggregation\\spec.md": 1770703840.6947522,
|
||||
"openspec\\changes\\archive\\2026-02-10-better-cache\\specs\\wordpress-content-source\\spec.md": 1770703850.2886736,
|
||||
"openspec\\changes\\archive\\2026-02-10-better-tracking\\design.md": 1770705507.058264,
|
||||
"openspec\\changes\\archive\\2026-02-10-better-tracking\\proposal.md": 1770705406.445285,
|
||||
"openspec\\changes\\archive\\2026-02-10-better-tracking\\tasks.md": 1770705742.4905672,
|
||||
"openspec\\changes\\archive\\2026-02-10-better-tracking\\specs\\analytics-umami\\spec.md": 1770705488.2900243,
|
||||
"openspec\\changes\\archive\\2026-02-10-better-tracking\\specs\\interaction-tracking-taxonomy\\spec.md": 1770705478.2533162,
|
||||
"openspec\\changes\\archive\\2026-02-10-blog-umami-fix\\design.md": 1770704655.2887678,
|
||||
"openspec\\changes\\archive\\2026-02-10-blog-umami-fix\\proposal.md": 1770704562.6725414,
|
||||
"openspec\\changes\\archive\\2026-02-10-blog-umami-fix\\tasks.md": 1770705110.210762,
|
||||
"openspec\\changes\\archive\\2026-02-10-blog-umami-fix\\specs\\analytics-umami\\spec.md": 1770704664.1777132,
|
||||
"openspec\\changes\\archive\\2026-02-10-blog-umami-fix\\specs\\blog-section-surface\\spec.md": 1770704682.900847,
|
||||
"openspec\\changes\\archive\\2026-02-10-blog-umami-fix\\specs\\interaction-tracking-taxonomy\\spec.md": 1770704671.2466474,
|
||||
"openspec\\changes\\archive\\2026-02-10-blogs-section\\design.md": 1770702604.1822934,
|
||||
"openspec\\changes\\archive\\2026-02-10-blogs-section\\proposal.md": 1770702501.7027605,
|
||||
"openspec\\changes\\archive\\2026-02-10-blogs-section\\tasks.md": 1770703159.5544684,
|
||||
"openspec\\changes\\archive\\2026-02-10-blogs-section\\specs\\blog-section-surface\\spec.md": 1770702645.5590143,
|
||||
"openspec\\changes\\archive\\2026-02-10-blogs-section\\specs\\seo-content-surface\\spec.md": 1770702651.325966,
|
||||
"openspec\\changes\\archive\\2026-02-10-blogs-section\\specs\\wordpress-content-source\\spec.md": 1770702632.988938,
|
||||
"openspec\\changes\\archive\\2026-02-10-card-layout\\design.md": 1770707076.6786592,
|
||||
"openspec\\changes\\archive\\2026-02-10-card-layout\\proposal.md": 1770706949.8167002,
|
||||
"openspec\\changes\\archive\\2026-02-10-card-layout\\tasks.md": 1770707574.8295856,
|
||||
"openspec\\changes\\archive\\2026-02-10-card-layout\\specs\\blog-section-surface\\spec.md": 1770709072.656981,
|
||||
"openspec\\changes\\archive\\2026-02-10-card-layout\\specs\\card-layout-system\\spec.md": 1770709066.1405613,
|
||||
"openspec\\changes\\archive\\2026-02-10-card-layout\\specs\\social-content-aggregation\\spec.md": 1770707042.9422977,
|
||||
"openspec\\changes\\archive\\2026-02-10-custom-events-umami\\design.md": 1770698186.0620773,
|
||||
"openspec\\changes\\archive\\2026-02-10-custom-events-umami\\proposal.md": 1770698096.4864,
|
||||
"openspec\\changes\\archive\\2026-02-10-custom-events-umami\\tasks.md": 1770699377.160174,
|
||||
"openspec\\changes\\archive\\2026-02-10-custom-events-umami\\specs\\analytics-umami\\spec.md": 1770699508.9692678,
|
||||
"openspec\\changes\\archive\\2026-02-10-custom-events-umami\\specs\\conversion-ctas\\spec.md": 1770699508.970271,
|
||||
"openspec\\changes\\archive\\2026-02-10-custom-events-umami\\specs\\interaction-tracking-taxonomy\\spec.md": 1770699508.970271,
|
||||
"openspec\\changes\\archive\\2026-02-10-deploy-without-node\\design.md": 1770707374.7418158,
|
||||
"openspec\\changes\\archive\\2026-02-10-deploy-without-node\\proposal.md": 1770707214.776635,
|
||||
"openspec\\changes\\archive\\2026-02-10-deploy-without-node\\tasks.md": 1770709577.2629378,
|
||||
"openspec\\changes\\archive\\2026-02-10-deploy-without-node\\specs\\docker-content-refresh\\spec.md": 1770707356.346542,
|
||||
"openspec\\changes\\archive\\2026-02-10-dynamic-homepage-social-acquisition\\design.md": 1770694717.0404153,
|
||||
"openspec\\changes\\archive\\2026-02-10-dynamic-homepage-social-acquisition\\proposal.md": 1770694008.3186584,
|
||||
"openspec\\changes\\archive\\2026-02-10-dynamic-homepage-social-acquisition\\tasks.md": 1770696978.4067898,
|
||||
"openspec\\changes\\archive\\2026-02-10-dynamic-homepage-social-acquisition\\specs\\analytics-umami\\spec.md": 1770694818.6898234,
|
||||
"openspec\\changes\\archive\\2026-02-10-dynamic-homepage-social-acquisition\\specs\\conversion-ctas\\spec.md": 1770694818.689301,
|
||||
"openspec\\changes\\archive\\2026-02-10-dynamic-homepage-social-acquisition\\specs\\homepage-content-modules\\spec.md": 1770694818.6882658,
|
||||
"openspec\\changes\\archive\\2026-02-10-dynamic-homepage-social-acquisition\\specs\\seo-content-surface\\spec.md": 1770694818.689301,
|
||||
"openspec\\changes\\archive\\2026-02-10-dynamic-homepage-social-acquisition\\specs\\social-content-aggregation\\spec.md": 1770694818.6882658,
|
||||
"openspec\\changes\\archive\\2026-02-10-fix-sub-pages\\design.md": 1770700011.169449,
|
||||
"openspec\\changes\\archive\\2026-02-10-fix-sub-pages\\proposal.md": 1770699997.097304,
|
||||
"openspec\\changes\\archive\\2026-02-10-fix-sub-pages\\tasks.md": 1770700170.4931765,
|
||||
"openspec\\changes\\archive\\2026-02-10-fix-sub-pages\\specs\\seo-content-surface\\spec.md": 1770700019.211279,
|
||||
"openspec\\changes\\archive\\2026-02-10-hide-ig-if-no-data\\design.md": 1770701301.1565921,
|
||||
"openspec\\changes\\archive\\2026-02-10-hide-ig-if-no-data\\proposal.md": 1770701242.1272779,
|
||||
"openspec\\changes\\archive\\2026-02-10-hide-ig-if-no-data\\tasks.md": 1770701782.6076858,
|
||||
"openspec\\changes\\archive\\2026-02-10-hide-ig-if-no-data\\specs\\homepage-content-modules\\spec.md": 1770701314.463359,
|
||||
"openspec\\changes\\archive\\2026-02-10-lazy-loading\\design.md": 1770754464.6107624,
|
||||
"openspec\\changes\\archive\\2026-02-10-lazy-loading\\proposal.md": 1770754317.0704117,
|
||||
"openspec\\changes\\archive\\2026-02-10-lazy-loading\\tasks.md": 1770756003.4601965,
|
||||
"openspec\\changes\\archive\\2026-02-10-lazy-loading\\specs\\card-layout-system\\spec.md": 1770754502.9136808,
|
||||
"openspec\\changes\\archive\\2026-02-10-lazy-loading\\specs\\image-lazy-loading\\spec.md": 1770754492.779088,
|
||||
"openspec\\changes\\archive\\2026-02-10-lighthouse-fixes\\baseline-audits.md": 1770778202.0321891,
|
||||
"openspec\\changes\\archive\\2026-02-10-lighthouse-fixes\\design.md": 1770776916.8084137,
|
||||
"openspec\\changes\\archive\\2026-02-10-lighthouse-fixes\\proposal.md": 1770776761.5123682,
|
||||
"openspec\\changes\\archive\\2026-02-10-lighthouse-fixes\\tasks.md": 1770780194.3872564,
|
||||
"openspec\\changes\\archive\\2026-02-10-lighthouse-fixes\\specs\\lighthouse-quality-gate\\spec.md": 1770777148.4286618,
|
||||
"openspec\\changes\\archive\\2026-02-10-lighthouse-fixes\\specs\\media-modal\\spec.md": 1770777148.4321685,
|
||||
"openspec\\changes\\archive\\2026-02-10-lighthouse-fixes\\specs\\responsive-image-delivery\\spec.md": 1770777148.4301648,
|
||||
"openspec\\changes\\archive\\2026-02-10-lighthouse-fixes\\specs\\seo-content-surface\\spec.md": 1770777148.4311683,
|
||||
"openspec\\changes\\archive\\2026-02-10-lighthouse-fixes\\specs\\service-worker-performance\\spec.md": 1770777148.4341688,
|
||||
"openspec\\changes\\archive\\2026-02-10-lighthouse-fixes\\specs\\site-theming\\spec.md": 1770777148.4331682,
|
||||
"openspec\\changes\\archive\\2026-02-10-lighthouse-fixes\\specs\\wcag-responsive-ui\\spec.md": 1770777148.4331682,
|
||||
"openspec\\changes\\archive\\2026-02-10-reduce-bounce-rate\\design.md": 1770762430.7436118,
|
||||
"openspec\\changes\\archive\\2026-02-10-reduce-bounce-rate\\proposal.md": 1770762430.7230804,
|
||||
"openspec\\changes\\archive\\2026-02-10-reduce-bounce-rate\\tasks.md": 1770762388.7942297,
|
||||
"openspec\\changes\\archive\\2026-02-10-reduce-bounce-rate\\specs\\analytics-umami\\spec.md": 1770751643.803513,
|
||||
"openspec\\changes\\archive\\2026-02-10-reduce-bounce-rate\\specs\\card-layout-system\\spec.md": 1770751609.4006913,
|
||||
"openspec\\changes\\archive\\2026-02-10-reduce-bounce-rate\\specs\\conversion-ctas\\spec.md": 1770762298.293718,
|
||||
"openspec\\changes\\archive\\2026-02-10-reduce-bounce-rate\\specs\\interaction-tracking-taxonomy\\spec.md": 1770762310.9267697,
|
||||
"openspec\\changes\\archive\\2026-02-10-reduce-bounce-rate\\specs\\media-modal\\spec.md": 1770762325.8561277,
|
||||
"openspec\\changes\\archive\\2026-02-10-service-workers\\design.md": 1770716121.2041433,
|
||||
"openspec\\changes\\archive\\2026-02-10-service-workers\\proposal.md": 1770716121.2041433,
|
||||
"openspec\\changes\\archive\\2026-02-10-service-workers\\tasks.md": 1770716818.0964458,
|
||||
"openspec\\changes\\archive\\2026-02-10-service-workers\\specs\\service-worker-performance\\spec.md": 1770716121.2059233,
|
||||
"openspec\\changes\\archive\\2026-02-10-wcag-responsive\\design.md": 1770710504.0920277,
|
||||
"openspec\\changes\\archive\\2026-02-10-wcag-responsive\\proposal.md": 1770710381.4593272,
|
||||
"openspec\\changes\\archive\\2026-02-10-wcag-responsive\\tasks.md": 1770711283.7843459,
|
||||
"openspec\\changes\\archive\\2026-02-10-wcag-responsive\\specs\\wcag-responsive-ui\\spec.md": 1770710523.4914804,
|
||||
"openspec\\changes\\archive\\2026-02-11-dch-theming\\design.md": 1770767108.971421,
|
||||
"openspec\\changes\\archive\\2026-02-11-dch-theming\\proposal.md": 1770766934.7293966,
|
||||
"openspec\\changes\\archive\\2026-02-11-dch-theming\\tasks.md": 1770771885.6166718,
|
||||
"openspec\\changes\\archive\\2026-02-11-dch-theming\\specs\\site-theming\\spec.md": 1770767154.674113,
|
||||
"openspec\\changes\\archive\\2026-02-11-dch-theming\\specs\\theme-switcher-notch\\spec.md": 1770767168.3042498,
|
||||
"openspec\\changes\\archive\\2026-02-11-dch-theming\\specs\\wcag-responsive-ui\\spec.md": 1770767178.9348993,
|
||||
"openspec\\changes\\archive\\2026-02-11-final-touches\\design.md": 1770782754.2948294,
|
||||
"openspec\\changes\\archive\\2026-02-11-final-touches\\proposal.md": 1770782748.2698598,
|
||||
"openspec\\changes\\archive\\2026-02-11-final-touches\\tasks.md": 1770782741.2076168,
|
||||
"openspec\\changes\\archive\\2026-02-11-final-touches\\specs\\nav-hover-line\\spec.md": 1770782735.06448,
|
||||
"openspec\\changes\\archive\\2026-02-11-final-touches\\specs\\navbar-branding\\spec.md": 1770781643.923366,
|
||||
"openspec\\changes\\archive\\2026-02-11-remember-theme\\design.md": 1770772744.6321404,
|
||||
"openspec\\changes\\archive\\2026-02-11-remember-theme\\proposal.md": 1770772417.4151812,
|
||||
"openspec\\changes\\archive\\2026-02-11-remember-theme\\tasks.md": 1770773835.9645731,
|
||||
"openspec\\changes\\archive\\2026-02-11-remember-theme\\specs\\analytics-umami\\spec.md": 1770772771.2684093,
|
||||
"openspec\\changes\\archive\\2026-02-11-remember-theme\\specs\\interaction-tracking-taxonomy\\spec.md": 1770772779.25797,
|
||||
"openspec\\changes\\archive\\2026-02-11-remember-theme\\specs\\site-theming\\spec.md": 1770772762.8810704,
|
||||
"openspec\\specs\\analytics-umami\\spec.md": 1770773611.7136278,
|
||||
"openspec\\specs\\blog-section-surface\\spec.md": 1770708953.3703814,
|
||||
"openspec\\specs\\cache-layer\\spec.md": 1770704360.452241,
|
||||
"openspec\\specs\\card-layout-system\\spec.md": 1770762704.0072663,
|
||||
"openspec\\specs\\conversion-ctas\\spec.md": 1770762600.9134228,
|
||||
"openspec\\specs\\docker-content-refresh\\spec.md": 1770709866.7621095,
|
||||
"openspec\\specs\\homepage-content-modules\\spec.md": 1770703572.5904553,
|
||||
"openspec\\specs\\image-lazy-loading\\spec.md": 1770756060.4065144,
|
||||
"openspec\\specs\\interaction-tracking-taxonomy\\spec.md": 1770773621.7074296,
|
||||
"openspec\\specs\\lighthouse-quality-gate\\spec.md": 1770780751.8868713,
|
||||
"openspec\\specs\\media-modal\\spec.md": 1770780711.707662,
|
||||
"openspec\\specs\\nav-hover-line\\spec.md": 1770782770.284241,
|
||||
"openspec\\specs\\navbar-branding\\spec.md": 1770782770.2854366,
|
||||
"openspec\\specs\\responsive-image-delivery\\spec.md": 1770780751.8878753,
|
||||
"openspec\\specs\\seo-content-surface\\spec.md": 1770780729.5234754,
|
||||
"openspec\\specs\\service-worker-performance\\spec.md": 1770780629.3490484,
|
||||
"openspec\\specs\\site-theming\\spec.md": 1770780660.5313509,
|
||||
"openspec\\specs\\social-content-aggregation\\spec.md": 1770708963.4293706,
|
||||
"openspec\\specs\\theme-switcher-notch\\spec.md": 1770772046.8918262,
|
||||
"openspec\\specs\\wcag-responsive-ui\\spec.md": 1770780642.3173912,
|
||||
"openspec\\specs\\wordpress-content-source\\spec.md": 1770704349.2461488,
|
||||
"site\\README.md": 1770695414.2709694,
|
||||
"site\\docs\\lighthouse.md": 1770779458.022776,
|
||||
"site\\public\\robots.txt": 1770777495.024902,
|
||||
"blog-screenshot.png": 1770774244.9107263,
|
||||
"csp-validation-final.png": 1770779783.4508164,
|
||||
"header-desktop-1366x768.png": 1770782205.9854114,
|
||||
"header-hover-effect-desktop.png": 1770782268.1240194,
|
||||
"header-hover-videos.png": 1770782218.7184997,
|
||||
"header-mobile-390x844.png": 1770782209.5139008,
|
||||
"homepage-screenshot.png": 1770774200.8950064,
|
||||
"site\\public\\favicon.png": 1770724544.5309453,
|
||||
"site\\public\\favicon.svg": 1770725206.2187872
|
||||
}
|
||||
BIN
header-desktop-1366x768.png
Normal file
|
After Width: | Height: | Size: 375 KiB |
BIN
header-hover-effect-desktop.png
Normal file
|
After Width: | Height: | Size: 376 KiB |
BIN
header-hover-videos.png
Normal file
|
After Width: | Height: | Size: 376 KiB |
BIN
header-mobile-390x844.png
Normal file
|
After Width: | Height: | Size: 181 KiB |
BIN
homepage-screenshot.png
Normal file
|
After Width: | Height: | Size: 800 KiB |
48
openspec/changes/archive/2026-02-10-better-cache/design.md
Normal file
@@ -0,0 +1,48 @@
|
||||
## Context
|
||||
|
||||
The site is an Astro static build served via nginx. Content is gathered by build-time ingestion (`site/scripts/fetch-content.ts`) that reads/writes a repo-local cache file (`site/content/cache/content.json`).
|
||||
|
||||
Today, repeated ingestion runs can re-hit external sources (YouTube API/RSS, podcast RSS, WordPress `wp-json`) and re-do normalization work. We want a shared caching layer to reduce IO and network load and to make repeated runs faster and more predictable.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Add a Redis-backed cache layer usable from Node scripts (ingestion) with TTL-based invalidation.
|
||||
- Use the cache layer to reduce repeated network/API calls and parsing work for:
|
||||
- social content ingestion (YouTube/podcast/Instagram list)
|
||||
- WordPress `wp-json` ingestion
|
||||
- Provide a default “industry standard” TTL with environment override.
|
||||
- Add a manual cache clear command/script.
|
||||
- Provide verification (tests and/or logs) that cache hits occur and TTL expiration behaves as expected.
|
||||
|
||||
**Non-Goals:**
|
||||
- Adding a runtime server for the site (the site remains static HTML served by nginx).
|
||||
- Caching browser requests to nginx (no CDN/edge cache configuration in this change).
|
||||
- Perfect cache coherence across multiple machines/environments (dev+docker is the target).
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Decision: Use Redis as the shared cache backend (docker-compose service).**
|
||||
- Rationale: Redis is widely adopted, lightweight, supports TTLs natively, and is easy to run in dev via Docker.
|
||||
- Alternative considered: Local file-based cache only. Rejected because it doesn’t provide a shared service and is harder to invalidate consistently.
|
||||
|
||||
- **Decision: Cache at the “source fetch” and “normalized dataset” boundaries.**
|
||||
- Rationale: The biggest cost is network + parsing/normalization. Caching raw API responses (or normalized outputs) by source+params gives the best win.
|
||||
- Approach:
|
||||
- Cache keys like `youtube:api:<channelId>:<limit>`, `podcast:rss:<url>`, `wp:posts`, `wp:pages`, `wp:categories`.
|
||||
- Store JSON values, set TTL, and log hit/miss per key.
|
||||
|
||||
- **Decision: Default TTL = 1 hour (3600s), configurable via env.**
|
||||
- Rationale: A 1h TTL is a common baseline for content freshness vs load. It also aligns with typical ingestion schedules (hourly/daily).
|
||||
- Allow overrides for local testing and production tuning.
|
||||
|
||||
- **Decision: Cache clear script uses Redis `FLUSHDB` in the configured Redis database.**
|
||||
- Rationale: Simple manual operation and easy to verify.
|
||||
- Guardrail: Use a dedicated Redis DB index (e.g., `0` by default) so the script is scoped.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Risk] Redis introduces a new dependency and operational moving part. -> Mitigation: Keep Redis optional; ingestion should fall back to no-cache mode if Redis is not reachable.
|
||||
- [Risk] Stale content if TTL too long. -> Mitigation: Default to 1h and allow env override; provide manual clear command.
|
||||
- [Risk] Cache key mistakes lead to wrong content reuse. -> Mitigation: Centralize key generation and add tests for key uniqueness and TTL behavior.
|
||||
|
||||
28
openspec/changes/archive/2026-02-10-better-cache/proposal.md
Normal file
@@ -0,0 +1,28 @@
|
||||
## Why
|
||||
|
||||
Reduce IO and external fetch load by adding a shared caching layer so repeated requests for the same content do not re-hit disk/network unnecessarily.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add a caching layer (Redis or similar lightweight cache) used by the site’s data/ingestion flows.
|
||||
- Add a cache service to `docker-compose.yml`.
|
||||
- Define an industry-standard cache invalidation interval (TTL) with a sensible default and allow it to be configured via environment variables.
|
||||
- Add a script/command to manually clear the cache on demand.
|
||||
- Add verification that the cache is working (cache hits/misses and TTL behavior).
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `cache-layer`: Provide a shared caching service (Redis or equivalent) with TTL-based invalidation and a manual clear operation for the website’s data flows.
|
||||
|
||||
### Modified Capabilities
|
||||
- `social-content-aggregation`: Use the cache layer to avoid re-fetching or re-processing external content sources on repeated runs/requests.
|
||||
- `wordpress-content-source`: Use the cache layer to reduce repeated `wp-json` fetches and parsing work.
|
||||
|
||||
## Impact
|
||||
|
||||
- Deployment/local dev: add Redis (or equivalent) to `docker-compose.yml` and wire environment/config for connection + TTL.
|
||||
- Scripts/services: update ingestion/build-time fetch to read/write via cache and log hit/miss for verification.
|
||||
- Tooling: add a cache-clear script/command (and document usage).
|
||||
- Testing: add tests or a lightweight verification step proving cached reads are used and expire as expected.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Redis-backed cache service
|
||||
The system MUST provide a Redis-backed cache service for use by ingestion and content processing flows.
|
||||
|
||||
The cache service MUST be runnable in local development via Docker Compose.
|
||||
|
||||
#### Scenario: Cache service available in Docker
|
||||
- **WHEN** the Docker Compose stack is started
|
||||
- **THEN** a Redis service is available to other services/scripts on the internal network
|
||||
|
||||
### Requirement: TTL-based invalidation
|
||||
Cached entries MUST support TTL-based invalidation.
|
||||
|
||||
The system MUST define a default TTL and MUST allow overriding the TTL via environment/config.
|
||||
|
||||
#### Scenario: Default TTL applies
|
||||
- **WHEN** a cached entry is written without an explicit TTL override
|
||||
- **THEN** it expires after the configured default TTL
|
||||
|
||||
#### Scenario: TTL override applies
|
||||
- **WHEN** a TTL override is configured via environment/config
|
||||
- **THEN** new cached entries use that TTL for expiration
|
||||
|
||||
### Requirement: Cache key namespace
|
||||
Cache keys MUST be namespaced by source and parameters so that different data requests do not collide.
|
||||
|
||||
#### Scenario: Two different sources do not collide
|
||||
- **WHEN** the system caches a YouTube fetch and a WordPress fetch
|
||||
- **THEN** they use different key namespaces and do not overwrite each other
|
||||
|
||||
### Requirement: Manual cache clear
|
||||
The system MUST provide a script/command to manually clear the cache.
|
||||
|
||||
#### Scenario: Manual clear executed
|
||||
- **WHEN** a developer runs the cache clear command
|
||||
- **THEN** the cache is cleared and subsequent ingestion runs produce cache misses
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Refresh and caching
|
||||
The system MUST cache the latest successful ingestion output and MUST serve the cached data to the site renderer.
|
||||
|
||||
The system MUST support periodic refresh on a schedule (at minimum daily) and MUST support a manual refresh trigger.
|
||||
|
||||
On ingestion failure, the system MUST continue serving the most recent cached data.
|
||||
|
||||
The ingestion pipeline MUST use the cache layer (when configured and reachable) to reduce repeated network and parsing work for external sources (for example, YouTube API/RSS and podcast RSS).
|
||||
|
||||
#### Scenario: Scheduled refresh fails
|
||||
- **WHEN** a scheduled refresh run fails to fetch one or more sources
|
||||
- **THEN** the site continues to use the most recent successfully cached dataset
|
||||
|
||||
#### Scenario: Manual refresh requested
|
||||
- **WHEN** a manual refresh is triggered
|
||||
- **THEN** the system attempts ingestion immediately and updates the cache if ingestion succeeds
|
||||
|
||||
#### Scenario: Cache hit avoids refetch
|
||||
- **WHEN** a refresh run is executed within the cache TTL for a given source+parameters
|
||||
- **THEN** the ingestion pipeline uses cached data for that source instead of refetching over the network
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Build-time caching
|
||||
WordPress posts, pages, and categories MUST be written into the repo-local content cache used by the site build.
|
||||
|
||||
If the WordPress fetch fails, the system MUST NOT crash the entire build pipeline; it MUST either:
|
||||
- keep the last-known-good cached WordPress content (if present), or
|
||||
- store an empty WordPress dataset and allow the rest of the site to build.
|
||||
|
||||
When the cache layer is configured and reachable, the WordPress ingestion MUST cache `wp-json` responses (or normalized outputs) using a TTL so repeated ingestion runs avoid unnecessary network requests and parsing work.
|
||||
|
||||
#### Scenario: WordPress fetch fails
|
||||
- **WHEN** a WordPress API request fails
|
||||
- **THEN** the site build can still complete and the blog surface renders a graceful empty state
|
||||
|
||||
#### Scenario: Cache hit avoids wp-json refetch
|
||||
- **WHEN** WordPress ingestion is executed within the configured cache TTL
|
||||
- **THEN** it uses cached data instead of refetching from `wp-json`
|
||||
|
||||
26
openspec/changes/archive/2026-02-10-better-cache/tasks.md
Normal file
@@ -0,0 +1,26 @@
|
||||
## 1. Cache Service And Config
|
||||
|
||||
- [x] 1.1 Add Redis service to `docker-compose.yml` and wire basic health/ports for local dev
|
||||
- [x] 1.2 Add cache env/config variables (Redis URL/host+port, DB index, default TTL seconds) and document in `site/.env.example`
|
||||
|
||||
## 2. Cache Client And Utilities
|
||||
|
||||
- [x] 2.1 Add a small Redis cache client wrapper (get/set JSON with TTL, namespaced keys) for Node scripts
|
||||
- [x] 2.2 Add logging for cache hit/miss per key to support verification
|
||||
- [x] 2.3 Ensure caching is optional: if Redis is unreachable, ingestion proceeds without caching
|
||||
|
||||
## 3. Integrate With Ingestion
|
||||
|
||||
- [x] 3.1 Cache YouTube fetches (API and/or RSS) by source+params and reuse within TTL
|
||||
- [x] 3.2 Cache podcast RSS fetch by URL and reuse within TTL
|
||||
- [x] 3.3 Cache WordPress `wp-json` fetches (posts/pages/categories) and reuse within TTL
|
||||
|
||||
## 4. Cache Invalidation
|
||||
|
||||
- [x] 4.1 Add a command/script to manually clear the cache (scoped to configured Redis DB)
|
||||
- [x] 4.2 Document the cache clear command usage
|
||||
|
||||
## 5. Verification
|
||||
|
||||
- [x] 5.1 Add a test that exercises the cache wrapper (set/get JSON + TTL expiration behavior)
|
||||
- [x] 5.2 Add a test or build verification that a second ingestion run within TTL produces cache hits
|
||||
@@ -0,0 +1,56 @@
|
||||
## Context
|
||||
|
||||
The site uses Umami custom events via data attributes on clickables (e.g., navigation, CTAs, outbound links). Today, most tracked links include stable identifiers like `target_id`, `placement`, and (for links) `target_url`.
|
||||
|
||||
This is sufficient to measure *where* users clicked, but it is limited for content discovery because it does not capture content metadata (e.g., which specific video/post title was clicked). Umami supports adding additional event data via `data-umami-event-*` attributes, which are recorded as strings.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Add content metadata fields to Umami click tracking for content-related links:
|
||||
- `title` (human-readable title)
|
||||
- `type` (content type)
|
||||
- Apply consistently across content surfaces (videos, podcast, blog).
|
||||
- Keep existing taxonomy constraints intact:
|
||||
- stable deterministic `target_id`
|
||||
- `placement`
|
||||
- `target_url` for links
|
||||
- Avoid tracking PII.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Introducing JavaScript-based `window.umami.track` calls (continue using Umami data-attribute tracking).
|
||||
- Tracking clicks inside arbitrary WordPress-rendered HTML bodies (future enhancement if needed).
|
||||
- Changing Umami initialization or environment configuration.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Decision: Use Option 1 (separate `title` and `type` fields).**
|
||||
- Rationale: Makes reporting and filtering easier (segment by `type`, then list top `title`). Avoids parsing concatenated strings in analytics.
|
||||
- Alternative: Option 2 (single `title` field formatted as `[type]-[title]`). Rejected for reduced queryability.
|
||||
|
||||
- **Decision: Only apply `title`/`type` to content-related links (not all links).**
|
||||
- Rationale: Many links do not map cleanly to a single content item (e.g., category nav, pagination, generic navigation).
|
||||
|
||||
- **Decision: Normalize type values.**
|
||||
- Rationale: Stable `type` values enable dashboards to be reused over time.
|
||||
- Proposed set (from specs): `video`, `podcast_episode`, `blog_post`, `blog_page`.
|
||||
|
||||
- **Decision: Prefer shared components to propagate tracking fields.**
|
||||
- Rationale: Centralize logic and reduce missed clickables.
|
||||
- Approach:
|
||||
- Extend existing link/card components (where applicable) to accept optional `umamiTitle` and `umamiType` props.
|
||||
- For pages that render raw `<a>` tags directly, add attributes inline.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Risk] Title values can change over time (content edits) which may reduce longitudinal stability.
|
||||
- Mitigation: Keep `target_id` deterministic and stable; use `title` for reporting convenience only.
|
||||
|
||||
- [Risk] Very long titles.
|
||||
- Mitigation: Truncate `title` values to a reasonable length (e.g., 120-160 chars) at instrumentation time if needed.
|
||||
|
||||
- [Risk] Inconsistent application across surfaces.
|
||||
- Mitigation: Add tests that assert content clickables include `data-umami-event-title` and `data-umami-event-type` where applicable.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
## Why
|
||||
|
||||
Umami click tracking is currently limited to `target_id`/`placement`, which makes it harder to understand *which* specific content items (by title/type) users engage with most. Adding lightweight content metadata to click events enables clearer measurement and reporting.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Extend Umami click event instrumentation so content-related links include additional event data:
|
||||
- `data-umami-event-title`: the content title (e.g., post/video/episode/page title)
|
||||
- `data-umami-event-type`: the content type (e.g., `blog_post`, `blog_page`, `video`, `podcast_episode`)
|
||||
- Apply the above consistently across all instrumented content links (cards, lists, navigation items that represent a specific piece of content).
|
||||
- Ensure the metadata is additive and does not replace the existing deterministic identifiers:
|
||||
- keep `data-umami-event-target_id`
|
||||
- keep `data-umami-event-placement`
|
||||
- keep `data-umami-event-target_url` for links
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- (none)
|
||||
|
||||
### Modified Capabilities
|
||||
- `interaction-tracking-taxonomy`: add/standardize optional content metadata fields (`title`, `type`) for tracked click events, and define allowed values for `type`.
|
||||
- `analytics-umami`: require Umami Track Events data-attribute instrumentation to support the above additional `data-umami-event-*` properties on content-related clickables.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected code: shared link/card components and content listing/detail pages (videos, podcast, blog posts/pages, and any other instrumented content surfaces).
|
||||
- Data: Umami event payloads will include two additional string fields for content links; dashboards/reports can segment by `type` and view top-clicked items by `title`.
|
||||
@@ -0,0 +1,36 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Custom event tracking
|
||||
When Umami is enabled, the site MUST support custom event emission for:
|
||||
- `cta_click`
|
||||
- `outbound_click`
|
||||
- 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 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
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
## 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`
|
||||
|
||||
#### 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`
|
||||
|
||||
15
openspec/changes/archive/2026-02-10-better-tracking/tasks.md
Normal file
@@ -0,0 +1,15 @@
|
||||
## 1. Update Tracking Taxonomy
|
||||
|
||||
- [x] 1.1 Update shared Umami instrumentation patterns to support optional `title` and `type` event data for content links (without breaking existing events)
|
||||
- [x] 1.2 Ensure content `type` values are normalized (`video`, `podcast_episode`, `blog_post`, `blog_page`) and do not include PII
|
||||
|
||||
## 2. Instrument Content Surfaces
|
||||
|
||||
- [x] 2.1 Add `data-umami-event-title` and `data-umami-event-type` to video clickables (listing cards and detail navigation where applicable)
|
||||
- [x] 2.2 Add `data-umami-event-title` and `data-umami-event-type` to podcast clickables (listing cards and episode links)
|
||||
- [x] 2.3 Add `data-umami-event-title` and `data-umami-event-type` to blog clickables that represent specific content items (post cards, pages list links)
|
||||
|
||||
## 3. Verify
|
||||
|
||||
- [x] 3.1 Add/update tests to assert content clickables include `data-umami-event-title` and `data-umami-event-type` where required
|
||||
- [x] 3.2 Build the site and confirm representative pages render the new data attributes (videos listing, podcast listing, blog listing)
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-10
|
||||
44
openspec/changes/archive/2026-02-10-blog-umami-fix/design.md
Normal file
@@ -0,0 +1,44 @@
|
||||
## Context
|
||||
|
||||
The site uses Umami for analytics. Most site clickables are instrumented using Umami’s data-attribute method (`data-umami-event` and optional `data-umami-event-*` properties) so events are recorded automatically on click.
|
||||
|
||||
The Blog section was added recently and its clickables (post cards, category nav, page links) are not consistently emitting Umami events. This creates a measurement blind spot for the `/blog` surface.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Ensure all blog clickables emit Umami events using the documented data-attribute method.
|
||||
- Ensure every tracked clickable has a deterministic, unique `target_id` and includes at minimum `placement` and `target_url` per taxonomy.
|
||||
- Keep event names within Umami limits (<= 50 chars) and avoid sending event data without an event name.
|
||||
- Add tests to prevent regressions (blog pages/components should contain required Umami attributes).
|
||||
|
||||
**Non-Goals:**
|
||||
- Introducing custom JavaScript tracking (`window.umami.track`) for v1; we will use Umami’s data-attribute method.
|
||||
- Adding new analytics providers or changing Umami initialization.
|
||||
- Tracking PII or user-generated content in event properties.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Decision: Use Umami-native data attributes on every blog clickable.**
|
||||
- Rationale: Aligns with Umami’s “Track events” docs and the rest of the site’s tracking approach; avoids adding JS listeners that can interfere with other handlers.
|
||||
|
||||
- **Decision: Use consistent event names by clickable type.**
|
||||
- Rationale: Keeps reporting clean while still allowing segmentation via event properties.
|
||||
- Proposed:
|
||||
- `click` for internal navigation links (including blog category navigation)
|
||||
- `outbound_click` for external links (if any in blog chrome)
|
||||
|
||||
- **Decision: Add a deterministic `target_id` namespace for blog elements.**
|
||||
- Rationale: Blog has many repeated elements; we need unique IDs that remain stable across builds.
|
||||
- Proposed conventions:
|
||||
- Blog header link: `nav.blog`
|
||||
- Blog secondary nav: `blog.subnav.all`, `blog.subnav.pages`, `blog.subnav.category.<slug>`
|
||||
- Blog post card: `blog.card.post.<slug>` (placement `blog.index` or `blog.category.<slug>`)
|
||||
- Blog post detail back link: `blog.post.back`
|
||||
- Blog page list links: `blog.pages.link.<slug>`
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Risk] Some blog content areas render raw HTML from WordPress; links inside content are not instrumented. -> Mitigation: Track the blog chrome (cards/nav/back links) first; consider JS-based delegated tracking for content-body links in a future change if needed.
|
||||
- [Risk] Over-instrumentation adds noisy events. -> Mitigation: Keep event names simple, rely on `target_id` + `placement` for segmentation, and avoid tracking non-clickable elements.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
## Why
|
||||
|
||||
The Blog section’s click tracking is not firing reliably in Umami, which prevents measuring what users do in `/blog` and where they go next.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Update the Blog section UI so every clickable element uses Umami’s data-attribute event tracking format:
|
||||
- `data-umami-event="<event-name>"`
|
||||
- `data-umami-event-*` attributes for event data
|
||||
- Ensure every tracked clickable item has a unique, deterministic set of event data elements (especially `target_id`, `placement`, `target_url`) so clicks can be measured independently.
|
||||
- Add verification/tests to ensure Blog clickables are instrumented and follow the same taxonomy as the rest of the site.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- (none)
|
||||
|
||||
### Modified Capabilities
|
||||
- `blog-section-surface`: instrument blog clickables (post cards, post/page links, category secondary nav, blog header link) using Umami `data-umami-event` attributes.
|
||||
- `interaction-tracking-taxonomy`: extend/clarify tracking rules to cover blog-specific UI elements and namespaces for `target_id`.
|
||||
- `analytics-umami`: ensure the implementation adheres to Umami’s Track Events specification for data attributes.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected UI/components: blog pages and components under `site/src/pages/blog/` and `site/src/components/` (cards and secondary nav), plus any shared navigation link to `/blog`.
|
||||
- Testing: add/update tests to assert required Umami data attributes exist and are unique per clickable element in blog surfaces.
|
||||
@@ -0,0 +1,28 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Custom event tracking
|
||||
When Umami is enabled, the site MUST support custom event emission for:
|
||||
- `cta_click`
|
||||
- `outbound_click`
|
||||
- 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-*`
|
||||
|
||||
#### 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: 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
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Blog index listing (posts)
|
||||
The site MUST provide a blog index page at `/blog` that lists WordPress posts as cards containing:
|
||||
- featured image (when available)
|
||||
- title
|
||||
- excerpt/summary
|
||||
|
||||
The listing MUST be ordered by publish date descending (newest first).
|
||||
|
||||
Each post card MUST be instrumented with Umami Track Events data attributes and MUST include at minimum:
|
||||
- `data-umami-event`
|
||||
- `data-umami-event-target_id`
|
||||
- `data-umami-event-placement`
|
||||
- `data-umami-event-target_url`
|
||||
|
||||
#### Scenario: Blog index lists posts
|
||||
- **WHEN** the cached WordPress dataset contains posts
|
||||
- **THEN** `/blog` renders a list of post cards ordered by publish date descending
|
||||
|
||||
#### Scenario: Blog post card click is tracked
|
||||
- **WHEN** a user clicks a blog post card on `/blog`
|
||||
- **THEN** the click emits an Umami event with `target_id`, `placement`, and `target_url`
|
||||
|
||||
### Requirement: Category-based secondary navigation
|
||||
The blog section MUST render a secondary navigation under the header derived from the cached WordPress categories.
|
||||
|
||||
Selecting a category MUST navigate to a category listing page showing only posts in that category.
|
||||
|
||||
Each secondary navigation link MUST be instrumented with Umami Track Events data attributes and MUST include at minimum:
|
||||
- `data-umami-event`
|
||||
- `data-umami-event-target_id`
|
||||
- `data-umami-event-placement`
|
||||
- `data-umami-event-target_url`
|
||||
|
||||
#### Scenario: Category nav present
|
||||
- **WHEN** the cached WordPress dataset contains categories
|
||||
- **THEN** the blog section shows a secondary navigation with those categories
|
||||
|
||||
#### Scenario: Category listing filters posts
|
||||
- **WHEN** a user navigates to a category listing page
|
||||
- **THEN** only posts assigned to that category are listed
|
||||
|
||||
#### Scenario: Category nav click is tracked
|
||||
- **WHEN** a user clicks a category link in the blog secondary navigation
|
||||
- **THEN** the click emits an Umami event with `target_id`, `placement`, and `target_url`
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Unique identifier for every clickable item
|
||||
Every clickable item that is tracked MUST have a stable identifier (`target_id`) that is unique across the site (or unique within a documented namespace).
|
||||
|
||||
The identifier MUST be deterministic across builds for the same element and placement.
|
||||
|
||||
The taxonomy MUST define namespaces for repeated UI surfaces. For the blog surface, the following namespaces MUST be used:
|
||||
- `blog.subnav.*` for secondary navigation links
|
||||
- `blog.card.post.<slug>` for blog post cards
|
||||
- `blog.pages.link.<slug>` for blog page listing links
|
||||
- `blog.post.*` / `blog.page.*` for detail page chrome links (e.g., back links)
|
||||
|
||||
#### Scenario: Two links 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
|
||||
|
||||
15
openspec/changes/archive/2026-02-10-blog-umami-fix/tasks.md
Normal file
@@ -0,0 +1,15 @@
|
||||
## 1. Audit Blog Clickables
|
||||
|
||||
- [x] 1.1 Inventory blog clickables (`site/src/pages/blog/**`, `site/src/components/Blog*`) that should emit Umami events (post cards, category subnav, pages list links, detail chrome links)
|
||||
- [x] 1.2 Confirm each clickable has the required Umami attributes and a deterministic unique `target_id` per taxonomy
|
||||
|
||||
## 2. Implement Umami Attributes
|
||||
|
||||
- [x] 2.1 Instrument blog secondary navigation links with `data-umami-event` and required event data (`target_id`, `placement`, `target_url`)
|
||||
- [x] 2.2 Instrument blog post cards and any inline links in listing UIs with `data-umami-event` and required event data
|
||||
- [x] 2.3 Instrument blog detail page chrome links (e.g., Back) and pages listing links with required Umami attributes
|
||||
|
||||
## 3. Verify
|
||||
|
||||
- [x] 3.1 Add/update tests to assert blog components/pages contain Umami `data-umami-event` attributes (and key properties like `target_id`, `placement`, `target_url`)
|
||||
- [x] 3.2 Build the site and confirm `/blog` and a blog detail page render with instrumented clickables
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-10
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-10
|
||||
62
openspec/changes/archive/2026-02-10-card-layout/design.md
Normal file
@@ -0,0 +1,62 @@
|
||||
## Context
|
||||
|
||||
The site renders multiple card-like UI elements today:
|
||||
- videos/podcast listings use `site/src/components/ContentCard.astro`
|
||||
- blog listings use `site/src/components/BlogPostCard.astro`
|
||||
|
||||
These cards have different layouts and metadata placement. This change standardizes the card information architecture so all cards feel consistent.
|
||||
|
||||
The site is statically generated (Astro). Card layout consistency should be enforced primarily through shared components and shared CSS rather than copy/paste per page.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Define and implement a single, consistent card structure:
|
||||
- media (image/placeholder) at top
|
||||
- title
|
||||
- trimmed summary/excerpt
|
||||
- meta row: date (left) + views (right, if available)
|
||||
- footer: source label (youtube/podcast/blog/etc.)
|
||||
- Apply to all existing card surfaces:
|
||||
- `/videos` listing cards
|
||||
- `/podcast` listing cards
|
||||
- `/blog` post cards (and category listings)
|
||||
- Keep the layout resilient when fields are missing (no views, no image, no summary).
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Redesigning non-card list links (e.g., simple navigation links) into cards unless needed for consistency.
|
||||
- Changing Umami tracking taxonomy (attributes stay intact).
|
||||
- Large typographic or theme redesign beyond card structure/spacing.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Decision: Implement a shared Card component used by existing card components.**
|
||||
- Rationale: Centralizes markup and ensures layout stays consistent across surfaces.
|
||||
- Approach:
|
||||
- Create a new component (e.g., `Card.astro`) with props for:
|
||||
- `href`, `title`, `summary`, `imageUrl`, `dateLabel`, `viewsLabel`, `sourceLabel`
|
||||
- optional tracking attributes pass-through (keep existing `data-umami-*` behavior)
|
||||
- Update `ContentCard.astro` and `BlogPostCard.astro` to render the shared Card component.
|
||||
|
||||
- **Decision: Add an optional `summary` field to normalized items.**
|
||||
- Rationale: Enables the standard card layout to show trimmed summaries for videos/podcast, similar to blog excerpts.
|
||||
- Approach:
|
||||
- Extend the normalized content schema/types with `summary?: string`.
|
||||
- Populate it during ingestion where available (YouTube description snippet; podcast episode summary/description).
|
||||
|
||||
- **Decision: Views are optional and shown only when available.**
|
||||
- Rationale: Not all sources provide views; the layout should be consistent without forcing synthetic values.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Risk] Ingestion sources may provide very long summaries.
|
||||
- Mitigation: Standardize trimming logic in the card component (single truncation helper).
|
||||
|
||||
- [Risk] CSS regressions across multiple pages.
|
||||
- Mitigation: Add tests that assert key card structure/classes exist; verify build outputs for `/videos`, `/podcast`, `/blog`.
|
||||
|
||||
- [Risk] Blog post cards and content cards have different link targets (internal vs outbound).
|
||||
- Mitigation: Shared Card component should be able to render both internal links and external links (target/rel configurable).
|
||||
|
||||
29
openspec/changes/archive/2026-02-10-card-layout/proposal.md
Normal file
@@ -0,0 +1,29 @@
|
||||
## Why
|
||||
|
||||
The site currently renders multiple card variants (videos/podcast cards, blog post cards, etc.) with inconsistent structure and metadata placement, which makes the UI feel uneven. A standardized card layout will create a consistent UX across the website.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Standardize the UI structure for all content cards across the site:
|
||||
- featured image displayed prominently on top (when available)
|
||||
- title
|
||||
- summary/excerpt, trimmed
|
||||
- meta row with date (left) and views (right) when available (`space-between`)
|
||||
- footer row showing the content source (YouTube/podcast/blog/etc.)
|
||||
- Update existing card renderers/components to use the standardized structure and styling.
|
||||
- Where a content source does not provide one of the fields (for example, views for blog posts), the layout MUST still render cleanly with the missing field omitted.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `card-layout-system`: Define the standard card information architecture (image/title/summary/meta/footer) and rules for optional fields so all surfaces render consistently.
|
||||
|
||||
### Modified Capabilities
|
||||
- `social-content-aggregation`: Extend normalized content items to include an optional `summary`/`excerpt` field where available (e.g., YouTube description snippet, podcast episode summary) so non-blog cards can display a trimmed summary.
|
||||
- `blog-section-surface`: Standardize blog listing cards to include the meta row (publish date and optional views) and footer source label, consistent with the global card layout system.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected code: shared card/link components (e.g., `site/src/components/ContentCard.astro`, `site/src/components/BlogPostCard.astro`) and pages that render listings (`/`, `/videos`, `/podcast`, `/blog`).
|
||||
- Data model: normalized cached items may gain an optional summary field; ingestion code may need to populate it for YouTube/podcast.
|
||||
- Styling: global CSS updates to ensure consistent spacing/typography and footer/meta layout.
|
||||
@@ -0,0 +1,33 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Blog index listing (posts)
|
||||
The site MUST provide a blog index page at `/blog` that lists WordPress posts as cards containing:
|
||||
- featured image (when available)
|
||||
- title
|
||||
- excerpt/summary
|
||||
- publish date
|
||||
|
||||
The card MUST render a footer bar that includes:
|
||||
- publish date on the left
|
||||
- views on the right when available (if views are not provided by the dataset, the card MUST omit views without breaking layout)
|
||||
- a content source label (e.g., `blog`)
|
||||
|
||||
The listing MUST be ordered by publish date descending (newest first).
|
||||
|
||||
Each post card MUST be instrumented with Umami Track Events data attributes and MUST include at minimum:
|
||||
- `data-umami-event`
|
||||
- `data-umami-event-target_id`
|
||||
- `data-umami-event-placement`
|
||||
- `data-umami-event-target_url`
|
||||
|
||||
#### Scenario: Blog index lists posts
|
||||
- **WHEN** the cached WordPress dataset contains posts
|
||||
- **THEN** `/blog` renders a list of post cards ordered by publish date descending
|
||||
|
||||
#### Scenario: Blog post card click is tracked
|
||||
- **WHEN** a user clicks a blog post card on `/blog`
|
||||
- **THEN** the click emits an Umami event with `target_id`, `placement`, and `target_url`
|
||||
|
||||
#### Scenario: Blog post card layout is standardized
|
||||
- **WHEN** `/blog` renders a blog post card
|
||||
- **THEN** the card shows featured image (when available), title, trimmed excerpt, and a footer bar containing date, optional views, and a source label
|
||||
@@ -0,0 +1,27 @@
|
||||
## ADDED 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 bar 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
|
||||
@@ -0,0 +1,27 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Normalized content items
|
||||
The system MUST normalize all ingested items (YouTube videos, Instagram posts, podcast episodes) into a single internal schema so the website can render them consistently.
|
||||
|
||||
The normalized item MUST include at minimum:
|
||||
- `id` (stable within its source)
|
||||
- `source` (`youtube`, `instagram`, or `podcast`)
|
||||
- `url`
|
||||
- `title`
|
||||
- `publishedAt` (ISO-8601)
|
||||
- `thumbnailUrl` (optional)
|
||||
|
||||
The system MUST support an optional summary field on normalized items when available from the source:
|
||||
- `summary` (optional, short human-readable excerpt suitable for cards)
|
||||
|
||||
#### Scenario: Normalizing a YouTube video
|
||||
- **WHEN** the system ingests a YouTube video item
|
||||
- **THEN** it produces a normalized item containing `id`, `source: youtube`, `url`, `title`, and `publishedAt`
|
||||
|
||||
#### Scenario: Normalizing a podcast episode
|
||||
- **WHEN** the system ingests a podcast RSS episode
|
||||
- **THEN** it produces a normalized item containing `id`, `source: podcast`, `url`, `title`, and `publishedAt`
|
||||
|
||||
#### Scenario: Summary available
|
||||
- **WHEN** an ingested item provides summary/description content
|
||||
- **THEN** the normalized item includes a `summary` suitable for rendering in cards
|
||||
20
openspec/changes/archive/2026-02-10-card-layout/tasks.md
Normal file
@@ -0,0 +1,20 @@
|
||||
## 1. Card Component + Styles
|
||||
|
||||
- [x] 1.1 Create a shared card component implementing the standard card layout (media, title, summary, meta row, footer)
|
||||
- [x] 1.2 Add/adjust shared CSS so the card meta row uses `space-between` and the footer consistently shows the source label
|
||||
|
||||
## 2. Data Model Support
|
||||
|
||||
- [x] 2.1 Extend normalized `ContentItem` to support an optional `summary` field and ensure it is persisted in the content cache
|
||||
- [x] 2.2 Populate `summary` for YouTube and podcast items during ingestion (safe trimming / fallback when missing)
|
||||
|
||||
## 3. Apply Across Site
|
||||
|
||||
- [x] 3.1 Update `ContentCard` surfaces (`/`, `/videos`, `/podcast`) to use the shared card layout and include date/views/source in the standard positions
|
||||
- [x] 3.2 Update blog post cards (`/blog`, category listings) to use the shared card layout (including publish date and `blog` source footer)
|
||||
- [x] 3.3 Ensure cards render cleanly when optional fields are missing (no image, no views, no summary)
|
||||
|
||||
## 4. Verify
|
||||
|
||||
- [x] 4.1 Add/update tests to assert standardized card structure/classes across `ContentCard` and blog post cards
|
||||
- [x] 4.2 Build the site and verify `/videos`, `/podcast`, and `/blog` render cards matching the standard layout
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-10
|
||||
@@ -0,0 +1,60 @@
|
||||
## Context
|
||||
|
||||
- Production server environment is intentionally minimal: Docker is available, but Node.js is not installed on the host.
|
||||
- The site needs a repeatable way to get to the latest built content on that server.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Update the deployed site to the latest content using Docker-only operations on the server.
|
||||
- Keep the server host clean (no Node.js installation required).
|
||||
- Make the refresh procedure repeatable and verifiable.
|
||||
|
||||
**Non-Goals:**
|
||||
- Building site artifacts directly on the server host outside containers.
|
||||
- Introducing a new CMS/content authoring workflow.
|
||||
- Solving content freshness triggers end-to-end (webhooks, scheduling) beyond what is needed to support a Docker-based refresh.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. Build in CI, deploy as a Docker image
|
||||
Why: keeps host clean and makes deploy deterministic.
|
||||
Alternatives considered:
|
||||
- Install Node.js on the host: rejected (violates clean server requirement).
|
||||
- Build on the host inside a one-off container writing to a bind mount/volume: possible, but adds operational complexity and makes server resources part of the build pipeline.
|
||||
|
||||
2. Refresh by pulling a published image and restarting the service
|
||||
Why: the server only needs Docker + registry access.
|
||||
Alternatives considered:
|
||||
- File-based sync (rsync/scp) of static assets: can work, but requires separate artifact distribution and is easier to drift.
|
||||
- Automated image updating (e.g., watchtower): may be useful later, but start with an explicit, documented operator command.
|
||||
|
||||
3. Version visibility via image metadata
|
||||
Why: operators need to confirm what is running.
|
||||
Approach:
|
||||
- Publish images with an immutable identifier (digest) and a human-friendly tag.
|
||||
- Expose build metadata through standard Docker inspection and/or a small endpoint/static file in the image.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Risk] Content can be stale if the CI build does not run when content changes
|
||||
Mitigation: add a scheduled build and/or content-change trigger in CI (future enhancement if not already present).
|
||||
|
||||
- [Risk] Registry auth/secrets management on the server
|
||||
Mitigation: use least-privilege registry credentials and Docker-native secret handling where available.
|
||||
|
||||
- [Risk] Short downtime during restart
|
||||
Mitigation: use `docker compose up -d` to minimize downtime; consider health checks and rolling strategies if/when multiple replicas are used.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
- Add or update the Docker image build to produce a deployable image containing the built site output.
|
||||
- Update server deployment configuration (compose/service) to run the published image.
|
||||
- Document the operator refresh command(s): pull latest image, restart service, verify deployed version.
|
||||
- Rollback strategy: re-deploy the previously known-good image tag/digest.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- What is the authoritative "latest content" source (e.g., WordPress, filesystem, git) and what is the trigger to rebuild/publish a new image?
|
||||
- Where should operator commands live (Makefile, `ops/` scripts, README section)?
|
||||
- What is the current deployment target (single host compose, swarm, k8s) and should this change be scoped to one?
|
||||
@@ -0,0 +1,25 @@
|
||||
## Why
|
||||
|
||||
The production server only provides Docker and does not have Node.js installed. We need a way to refresh the site to the latest content on that server without installing Node.js on the host.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add a Docker-first mechanism to update the deployed site to the latest content without requiring host-installed build tooling (no Node.js on the server).
|
||||
- Standardize the deploy/update flow so the server updates are performed via Docker (e.g., pulling a new artifact/image and restarting, or running a containerized refresh job).
|
||||
- Document and automate the update command(s) so content refresh is repeatable and low-risk.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `docker-content-refresh`: The deployed site can be updated to the latest content on a Docker-only server (no host Node.js), using a containerized workflow.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
None.
|
||||
|
||||
## Impact
|
||||
|
||||
- Deployment/runtime: Docker compose/service definitions, update procedure, and operational docs.
|
||||
- CI/CD: build/publish pipeline may need to produce and publish deployable artifacts suitable for Docker-only servers.
|
||||
- Secrets/credentials: any content source credentials needed for refresh/build must be handled via Docker-friendly secret injection.
|
||||
- Observability/ops: add or adjust logging/health checks around the refresh/update step to make failures visible.
|
||||
@@ -0,0 +1,26 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Host update does not require Node.js
|
||||
The system MUST provide an operator workflow to update the deployed site to the latest content without installing Node.js on the server host. Any build or content-fetch steps MUST run in containers and/or CI, not via host-installed Node.js.
|
||||
|
||||
#### Scenario: Operator updates without host Node.js
|
||||
- **WHEN** the server host has Docker available but does not have Node.js installed
|
||||
- **THEN** the operator can complete the update procedure using Docker commands only
|
||||
|
||||
### Requirement: Image-based content refresh is supported
|
||||
The system MUST support refreshing the deployed site to the latest content by pulling a newly built deployable artifact (for example, a Docker image) and restarting the running service.
|
||||
|
||||
#### Scenario: Successful refresh to latest image
|
||||
- **WHEN** the operator runs the documented refresh command
|
||||
- **THEN** the server pulls the latest published image and restarts the service using that image
|
||||
|
||||
#### Scenario: Refresh failure does not break running site
|
||||
- **WHEN** the operator runs the documented refresh command and the pull fails
|
||||
- **THEN** the site remains running on the previously deployed image
|
||||
|
||||
### Requirement: Refresh is repeatable and auditable
|
||||
The system MUST document the refresh procedure and provide a way to verify which version is deployed (for example, image tag/digest or build metadata).
|
||||
|
||||
#### Scenario: Operator verifies deployed version
|
||||
- **WHEN** the operator runs the documented verification command
|
||||
- **THEN** the system reports the currently deployed version identifier
|
||||
@@ -0,0 +1,25 @@
|
||||
## 1. Discovery And Current State
|
||||
|
||||
- [x] 1.1 Identify current deploy target and mechanism (compose/swarm/k8s, image vs files) and document constraints in `README` or `ops/` docs
|
||||
- [x] 1.2 Identify the content source(s) that define "latest content" (e.g., WordPress/API) and how builds currently fetch content
|
||||
- [x] 1.3 Confirm current build output (static assets) and runtime server (e.g., nginx) requirements
|
||||
|
||||
## 2. Build And Publish A Deployable Artifact
|
||||
|
||||
- [x] 2.1 Ensure the repo can produce a deterministic production build inside CI (no host dependencies)
|
||||
- [x] 2.2 Create or update a Dockerfile to build the site and package the built output into a runtime image
|
||||
- [x] 2.3 Add build metadata to the image (tagging convention and/or embedded version file)
|
||||
- [x] 2.4 Configure CI to build and publish the image to a registry accessible by the server
|
||||
|
||||
## 3. Server-Side Docker-Only Refresh Workflow
|
||||
|
||||
- [x] 3.1 Add or update the server Docker Compose/service definition to run the published image
|
||||
- [x] 3.2 Add documented operator commands to refresh to the latest image (pull + restart)
|
||||
- [x] 3.3 Add a verification command/procedure to show the currently deployed version (tag/digest/build metadata)
|
||||
- [x] 3.4 Define rollback procedure to re-deploy a previous known-good tag/digest
|
||||
|
||||
## 4. Validation
|
||||
|
||||
- [x] 4.1 Validate a refresh on a test/staging server: pull latest image, restart, confirm content changes are visible
|
||||
- [x] 4.2 Validate failure mode: simulate pull failure and confirm the existing site remains serving
|
||||
- [x] 4.3 Update docs with a minimal "runbook" for operators (refresh, verify, rollback)
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-10
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-10
|
||||
74
openspec/changes/archive/2026-02-10-lazy-loading/design.md
Normal file
@@ -0,0 +1,74 @@
|
||||
## Context
|
||||
|
||||
The site is a static Astro SSG with no framework islands — all client-side interactivity uses vanilla JS via `<script is:inline>` tags. Images appear in two surfaces:
|
||||
|
||||
1. **Card thumbnails** — `StandardCard.astro` renders `<img src={imageUrl} alt="" loading="lazy" />` inside a `.card-media` div. When there's no image, a static `.card-placeholder` div fills the space. The image area is fixed at 180px height (200px on mobile).
|
||||
2. **Blog featured images** — `blog/post/[slug].astro` and `blog/page/[slug].astro` render `<img loading="lazy" />` inline with a max-height of 420px.
|
||||
|
||||
Currently `loading="lazy"` defers fetching until the image nears the viewport, but the image area is blank/transparent until download completes, causing a jarring pop-in.
|
||||
|
||||
The site's dark theme (`--bg0: #0b1020`) means the shimmer must be a subtle light-on-dark gradient sweep — not a grey-on-white pattern.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Show an animated shimmer placeholder while images load, matching the site's dark aesthetic
|
||||
- Fade in the actual image smoothly once loaded
|
||||
- Handle image load errors gracefully (keep placeholder visible)
|
||||
- Respect `prefers-reduced-motion` (suppress shimmer animation)
|
||||
- Zero new dependencies — CSS keyframes + vanilla JS only
|
||||
|
||||
**Non-Goals:**
|
||||
- Generating low-quality image placeholders (LQIP/BlurHash) at build time — this requires a build pipeline change and image processing dependency
|
||||
- Lazy loading changes to the `loading` attribute strategy — `loading="lazy"` is already set and works correctly
|
||||
- Image format optimization (WebP/AVIF conversion) — separate concern
|
||||
- Placeholder for Instagram embeds — those use a third-party embed script with its own loading state
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. CSS shimmer via `@keyframes` on a pseudo-element
|
||||
|
||||
**Decision:** The shimmer effect uses a CSS `@keyframes` animation on a `::before` pseudo-element of the image wrapper. The pseudo-element displays a translucent gradient that sweeps left-to-right.
|
||||
|
||||
**Rationale:** Pure CSS, no JS needed for the animation. Pseudo-elements avoid extra DOM nodes. The existing `.card-placeholder` already fills the image area — the shimmer just adds motion on top of it.
|
||||
|
||||
**Alternatives considered:**
|
||||
- SVG animated placeholder: More complex markup, no visual benefit for a simple shimmer.
|
||||
- JS-driven animation (`requestAnimationFrame`): Heavier, no benefit over CSS keyframes.
|
||||
- `<canvas>` BlurHash: Requires a build-time hash generation pipeline — overkill for a shimmer.
|
||||
|
||||
### 2. Image starts hidden, fades in on `load` event
|
||||
|
||||
**Decision:** The `<img>` element renders with `opacity: 0` (via a CSS class like `.img-loading`). A small inline `<script>` listens for the `load` event on each image and removes the loading class, triggering a CSS `opacity` transition to fade in. The shimmer placeholder sits behind the image and is naturally hidden once the image becomes opaque.
|
||||
|
||||
**Rationale:** The `load` event is the reliable signal that the image is ready to display. `opacity` transition is GPU-composited and smooth. No layout shift because the image dimensions are fixed by the container (`.card-media img` has `height: 180px; object-fit: cover`).
|
||||
|
||||
**Implementation detail:** Images may already be cached by the browser (instant load). The script must handle the case where `img.complete && img.naturalWidth > 0` on page load — immediately remove the loading class without waiting for the `load` event.
|
||||
|
||||
**Alternatives considered:**
|
||||
- `IntersectionObserver` to defer `src` assignment: Already handled by `loading="lazy"` — doubling up adds complexity for no benefit.
|
||||
- CSS-only `:not([src])` or `content-visibility`: No reliable CSS-only way to detect "image has loaded."
|
||||
|
||||
### 3. Single reusable wrapper pattern
|
||||
|
||||
**Decision:** Wrap each `<img>` in a container element with class `.img-shimmer-wrap`. This wrapper gets the shimmer pseudo-element and positions the image on top. The same wrapper pattern is used for both card thumbnails and blog featured images.
|
||||
|
||||
**Rationale:** One CSS class, one script, works everywhere. The wrapper inherits the size from its parent (`.card-media` for cards, inline styles for blog images), so no per-surface sizing logic is needed.
|
||||
|
||||
### 4. Error handling: keep placeholder visible
|
||||
|
||||
**Decision:** On image `error` event, the loading class stays on (image remains `opacity: 0`), so the shimmer/placeholder remains visible. The shimmer animation stops (replaced by a static placeholder state) to avoid an endlessly-animating error state.
|
||||
|
||||
**Rationale:** A static placeholder is better UX than a broken image icon or an infinite shimmer. The user sees a clean grey block instead of a broken experience.
|
||||
|
||||
### 5. Reduced motion: static placeholder, no shimmer sweep
|
||||
|
||||
**Decision:** The existing global CSS rule (`@media (prefers-reduced-motion: reduce)`) already suppresses all animations and transitions to near-zero duration. The shimmer animation inherits this behavior automatically — no additional media query needed.
|
||||
|
||||
**Rationale:** The global rule (`animation-duration: 0.001ms !important`) already covers all keyframe animations. The shimmer becomes a static gradient overlay, which is still a valid loading indicator without motion.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[Layout shift on blog images]** → Blog featured images use inline `max-height: 420px` but no explicit `width`/`height` attributes, which could cause minor CLS. Mitigation: the wrapper inherits `width: 100%` and `max-height: 420px` from the existing inline styles, so the placeholder reserves the correct space. No change in CLS from current behavior.
|
||||
- **[Already-cached images flash shimmer]** → If images are in the browser cache, they load near-instantly, but the shimmer might flicker briefly. Mitigation: the script checks `img.complete` on DOM ready and immediately reveals cached images — no visible shimmer for cached images.
|
||||
- **[Increased CSS size]** → The shimmer keyframes and wrapper styles add ~30 lines to `global.css`. Mitigation: negligible impact on a static site with a single CSS file.
|
||||
27
openspec/changes/archive/2026-02-10-lazy-loading/proposal.md
Normal file
@@ -0,0 +1,27 @@
|
||||
## Why
|
||||
|
||||
All images on the site use `loading="lazy"` but render as blank space until the browser finishes downloading them. On slower connections or pages with many cards (homepage has 20+ cards), the user sees empty grey rectangles that pop into view abruptly. Adding a shimmer/skeleton placeholder while images load gives the perception of a faster, more polished page — the same pattern used by LinkedIn, YouTube, and AMP pages.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Shimmer placeholder for card thumbnails.** The `.card-media` area displays an animated skeleton shimmer while the `<img>` is loading. Once the image loads, it fades in over the shimmer. If the image fails to load, the placeholder remains visible (graceful degradation).
|
||||
- **Shimmer placeholder for blog featured images.** Blog post and page detail pages show the same shimmer treatment on the hero/featured image while it loads.
|
||||
- **Reduced-motion support.** The shimmer animation is suppressed when `prefers-reduced-motion: reduce` is active — the placeholder shows as a static block instead of animating.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `image-lazy-loading`: Shimmer/skeleton placeholder system for images that displays an animated loading state while images download, fades in the image on load, and handles load failures gracefully.
|
||||
|
||||
### Modified Capabilities
|
||||
- `card-layout-system`: Card image area gains shimmer placeholder behavior during image loading (visual enhancement, no layout changes).
|
||||
|
||||
## Impact
|
||||
|
||||
- **Components**: `StandardCard.astro` — the `<img>` element needs a wrapper or sibling element for the shimmer, plus a script to detect load/error events.
|
||||
- **Pages**: `blog/post/[slug].astro`, `blog/page/[slug].astro` — featured images get the same shimmer treatment.
|
||||
- **CSS**: `global.css` — new shimmer animation keyframes and placeholder styles.
|
||||
- **JS**: Small inline script to listen for image `load`/`error` events and toggle visibility classes.
|
||||
- **No backend changes.** No data model, ingestion, or caching changes.
|
||||
- **No new dependencies.** Pure CSS animation + vanilla JS event listeners.
|
||||
- **Accessibility**: Shimmer respects `prefers-reduced-motion` (existing global rule covers animation suppression). No content or semantic changes.
|
||||
@@ -0,0 +1,35 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Standard card information architecture
|
||||
All content cards rendered by the site MUST use a standardized layout so cards across different surfaces look consistent.
|
||||
|
||||
The standard card layout MUST be:
|
||||
- featured image displayed prominently at the top (when available), with a shimmer placeholder visible while the image loads
|
||||
- title
|
||||
- summary/excerpt text, trimmed to a fixed maximum length
|
||||
- footer row showing:
|
||||
- publish date on the left
|
||||
- views when available (if omitted, the footer MUST still render cleanly)
|
||||
- the content source label (e.g., `youtube`, `podcast`, `blog`)
|
||||
|
||||
If a field is not available (for example, views for some sources), the card MUST still render cleanly with that field omitted.
|
||||
|
||||
#### Scenario: Card renders with all fields
|
||||
- **WHEN** a content item has an image, title, summary, publish date, views, and source
|
||||
- **THEN** the card renders those fields in the standard card layout order
|
||||
|
||||
#### Scenario: Card renders without views
|
||||
- **WHEN** a content item has no views data
|
||||
- **THEN** the card renders the footer bar with date + source and omits views without breaking the layout
|
||||
|
||||
#### Scenario: Card renders without featured image
|
||||
- **WHEN** a content item has no featured image
|
||||
- **THEN** the card renders a placeholder media area and still renders the remaining fields
|
||||
|
||||
#### Scenario: Card image shows shimmer while loading
|
||||
- **WHEN** a content item has an image URL and the image has not yet loaded
|
||||
- **THEN** the card media area displays an animated shimmer placeholder until the image loads and fades in
|
||||
|
||||
#### Scenario: Card image load failure shows static placeholder
|
||||
- **WHEN** a content item has an image URL but the image fails to load
|
||||
- **THEN** the card media area displays a static placeholder (no broken image icon) and the card remains visually intact
|
||||
@@ -0,0 +1,50 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Shimmer placeholder while images load
|
||||
Every site image that uses `loading="lazy"` MUST display an animated shimmer placeholder in its container while the image is downloading.
|
||||
|
||||
The shimmer MUST be a translucent gradient sweep animation that matches the site's dark theme.
|
||||
|
||||
The shimmer MUST be visible from the moment the page renders until the image finishes loading.
|
||||
|
||||
#### Scenario: Image loads successfully on slow connection
|
||||
- **WHEN** a page renders with a lazy-loaded image and the image takes time to download
|
||||
- **THEN** the image container displays an animated shimmer placeholder until the image finishes loading
|
||||
|
||||
#### Scenario: Image loads from browser cache
|
||||
- **WHEN** a page renders with a lazy-loaded image that is already in the browser cache
|
||||
- **THEN** the image displays immediately with no visible shimmer flicker
|
||||
|
||||
### Requirement: Fade-in transition on image load
|
||||
When a lazy-loaded image finishes downloading, it MUST fade in smoothly over the shimmer placeholder using a CSS opacity transition.
|
||||
|
||||
The fade-in duration MUST be short enough to feel responsive (no longer than 300ms).
|
||||
|
||||
#### Scenario: Image completes loading
|
||||
- **WHEN** a lazy-loaded image finishes downloading
|
||||
- **THEN** the image fades in over approximately 200–300ms, replacing the shimmer placeholder
|
||||
|
||||
### Requirement: Graceful degradation on image load failure
|
||||
If a lazy-loaded image fails to load (network error, 404, etc.), the shimmer animation MUST stop and the placeholder MUST remain visible as a static block.
|
||||
|
||||
The page MUST NOT display a broken image icon.
|
||||
|
||||
#### Scenario: Image fails to load
|
||||
- **WHEN** a lazy-loaded image triggers an error event (e.g., 404 or network failure)
|
||||
- **THEN** the shimmer animation stops and the container displays a static placeholder background instead of a broken image icon
|
||||
|
||||
### Requirement: Reduced motion support for shimmer
|
||||
The shimmer animation MUST be suppressed when the user has `prefers-reduced-motion: reduce` enabled.
|
||||
|
||||
When motion is reduced, the placeholder MUST still be visible as a static block (no animation), maintaining the loading indicator without motion.
|
||||
|
||||
#### Scenario: User has reduced motion enabled
|
||||
- **WHEN** a user with `prefers-reduced-motion: reduce` views a page with lazy-loaded images
|
||||
- **THEN** the placeholder is visible as a static block without any sweeping animation
|
||||
|
||||
### Requirement: No layout shift from shimmer
|
||||
The shimmer placeholder MUST NOT cause any cumulative layout shift (CLS). The placeholder MUST occupy the exact same dimensions as the image it replaces.
|
||||
|
||||
#### Scenario: Placeholder matches image dimensions
|
||||
- **WHEN** a page renders with a shimmer placeholder for a card thumbnail
|
||||
- **THEN** the placeholder occupies the same width and height as the image area (e.g., 100% width × 180px height for card thumbnails) with no layout shift when the image loads
|
||||
34
openspec/changes/archive/2026-02-10-lazy-loading/tasks.md
Normal file
@@ -0,0 +1,34 @@
|
||||
## 1. CSS shimmer styles
|
||||
|
||||
- [x] 1.1 Add `@keyframes shimmer` animation to `global.css` — a translucent gradient sweep (left-to-right) that works on the dark theme background (`--bg0`/`--bg1` palette).
|
||||
- [x] 1.2 Add `.img-shimmer-wrap` class to `global.css` — `position: relative; overflow: hidden;` container that inherits dimensions from its parent. Add a `::before` pseudo-element with the shimmer animation (full width/height, absolute positioned, translucent light gradient).
|
||||
- [x] 1.3 Add `.img-loading` class to `global.css` — sets `opacity: 0` on the `<img>` element. Add transition: `opacity 250ms ease`.
|
||||
- [x] 1.4 Add `.img-error` class to `global.css` — stops the shimmer animation on the wrapper (`animation: none` on `::before`) so the placeholder displays as a static block.
|
||||
- [x] 1.5 Verify the existing `@media (prefers-reduced-motion: reduce)` rule in `global.css` already suppresses the shimmer `@keyframes` animation (it should — the global rule sets `animation-duration: 0.001ms !important`).
|
||||
|
||||
## 2. Card thumbnail shimmer
|
||||
|
||||
- [x] 2.1 Update `StandardCard.astro` — wrap the existing `<img>` in a `<div class="img-shimmer-wrap">`. Add class `img-loading` to the `<img>` element. Keep the existing `.card-placeholder` fallback for cards with no image (no shimmer needed there).
|
||||
- [x] 2.2 Ensure the `.img-shimmer-wrap` inside `.card-media` inherits the correct dimensions (`width: 100%; height: 180px` on desktop, `200px` on mobile) without causing layout shift.
|
||||
|
||||
## 3. Blog featured image shimmer
|
||||
|
||||
- [x] 3.1 Update `blog/post/[slug].astro` — wrap the featured `<img>` in a `<div class="img-shimmer-wrap">` with matching inline styles (`width: 100%; max-height: 420px; border-radius: 16px; overflow: hidden;`). Add class `img-loading` to the `<img>`.
|
||||
- [x] 3.2 Update `blog/page/[slug].astro` — same shimmer wrapper treatment as blog posts.
|
||||
|
||||
## 4. Image load/error script
|
||||
|
||||
- [x] 4.1 Add an inline `<script is:inline>` in `BaseLayout.astro` (or a shared location) that runs on DOM ready. For every `img.img-loading` element: if `img.complete && img.naturalWidth > 0`, remove `img-loading` immediately (cached images). Otherwise, add `load` event listener to remove `img-loading` and `error` event listener to add `img-error` to the wrapper.
|
||||
- [x] 4.2 Verify the script handles dynamically added images (not currently needed — SSG renders all images server-side — but ensure the script runs after all DOM is ready).
|
||||
|
||||
## 5. Verification
|
||||
|
||||
- [x] 5.1 Run `npm run build` in `site/` and verify no build errors.
|
||||
- [x] 5.2 Run `npm test` in `site/` and verify all existing tests pass (1 pre-existing failure in blog-nav.test.ts unrelated to this change — expects `/about` nav link that doesn't exist).
|
||||
- [x] 5.3 Manual smoke test: throttle network in DevTools (Slow 3G), load homepage — verify shimmer appears on card thumbnails, images fade in on load.
|
||||
- [x] 5.4 Manual smoke test: load a blog post with a featured image on throttled network — verify shimmer and fade-in.
|
||||
- [x] 5.5 Manual smoke test: break an image URL temporarily — verify static placeholder shows (no broken image icon, no infinite shimmer).
|
||||
- [x] 5.6 Manual smoke test: enable `prefers-reduced-motion: reduce` in browser/OS settings — verify shimmer animation is suppressed (static placeholder, no sweep).
|
||||
- [x] 5.7 Verify no cumulative layout shift: compare card dimensions before and after the change using DevTools Layout Shift overlay.
|
||||
|
||||
Note: Tasks 5.3–5.7 require manual browser testing with DevTools network throttling.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-11
|
||||
@@ -0,0 +1,42 @@
|
||||
# Lighthouse Baseline (From Provided Reports)
|
||||
|
||||
Source reports used:
|
||||
|
||||
- `C:\Users\simpl\Downloads\santhoshj.com-light-mobile.json`
|
||||
- `C:\Users\simpl\Downloads\santhoshj.com-dark-mobile.json`
|
||||
- `C:\Users\simpl\Downloads\santhoshj.com-hc-mobile.json`
|
||||
- `C:\Users\simpl\Downloads\santhoshj.com-hc-desktop.json`
|
||||
- `C:\Users\simpl\Downloads\santhoshj.com-light-desktop.json`
|
||||
- `C:\Users\simpl\Downloads\santhoshj.com-dark-desktop.json`
|
||||
|
||||
## Category Scores (Baseline)
|
||||
|
||||
- `light-mobile`: performance 92, accessibility 100, best-practices 96, seo 85
|
||||
- `dark-mobile`: performance 83, accessibility 95, best-practices 96, seo 85
|
||||
- `hc-mobile`: performance 84, accessibility 100, best-practices 96, seo 85
|
||||
- `hc-desktop`: performance 98, accessibility 100, best-practices 96, seo 85
|
||||
- `light-desktop`: performance 99, accessibility 100, best-practices 96, seo 85
|
||||
- `dark-desktop`: performance 99, accessibility 95, best-practices 96, seo 85
|
||||
|
||||
## Failing Audits Seen In 6/6 Reports
|
||||
|
||||
- `unminified-css`
|
||||
- `unused-css-rules`
|
||||
- `unused-javascript`
|
||||
- `inspector-issues`
|
||||
- `crawlable-anchors`
|
||||
- `robots-txt`
|
||||
- `cache-insight`
|
||||
- `forced-reflow-insight`
|
||||
- `image-delivery-insight`
|
||||
- `network-dependency-tree-insight`
|
||||
- `render-blocking-insight`
|
||||
|
||||
## Intermittent / Variant-Specific Fails
|
||||
|
||||
- `color-contrast` (2/6)
|
||||
- `interactive` (3/6)
|
||||
|
||||
## Environmental Warning In Reports
|
||||
|
||||
- Chrome extension interference warning appears in reports. This is why the new gate requires clean profile runs.
|
||||
144
openspec/changes/archive/2026-02-10-lighthouse-fixes/design.md
Normal file
@@ -0,0 +1,144 @@
|
||||
## Context
|
||||
|
||||
The production site is built as static output and served via nginx (Docker image). Lighthouse runs (mobile/desktop, light/dark/high-contrast) report repeated misses across Performance, SEO, Best Practices, and (in dark theme) Accessibility.
|
||||
|
||||
Notable characteristics from the current system:
|
||||
|
||||
- SEO is mostly static-file driven (`site/public/robots.txt`, sitemap output) and must be correct for the deployed domain.
|
||||
- Global styling is served from `site/public/styles/global.css`, which bypasses typical bundler optimizations (minification, pruning) unless explicitly piped through the build.
|
||||
- A service worker exists (`site/public/sw.js`) and is involved in caching shell assets; cache behavior must not cause stale critical assets after deploy.
|
||||
- The site uses environment-driven configuration and has multiple theme modes (light/dark/high-contrast) that must remain visually consistent and accessible.
|
||||
|
||||
The goal is a deterministic, repeatable Lighthouse gate that can hit 100s in a clean run environment.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Reach 100 Lighthouse scores (Performance, Accessibility, Best Practices, SEO) for the defined set of URLs, form factors, and theme variants.
|
||||
- Fix SEO correctness issues that are unambiguous (e.g., `robots.txt` sitemap URL must be absolute for the production domain).
|
||||
- Ensure all user-visible navigational CTAs and modal actions are implemented as crawlable anchors (real `href`s) when they represent links.
|
||||
- Eliminate dark theme contrast regressions by tightening theme token choices and/or component-level styles.
|
||||
- Reduce initial render blocking work (fonts + CSS) to improve mobile performance.
|
||||
- Reduce CSS overhead by ensuring the global stylesheet is minified and sized appropriately for production.
|
||||
- Make Lighthouse runs deterministic (clean Chrome profile, fixed throttling/UA) and enforce a quality gate in CI or a scripted check.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Redesigning the visual aesthetic.
|
||||
- Building a general-purpose CSS framework migration.
|
||||
- Perfect scores when Lighthouse is run with extensions or non-deterministic settings.
|
||||
- Solving third-party performance/caching issues by weakening metrics (the goal is to reduce third-party dependence where it blocks 100s, or scope the gate to first-party pages that can be made deterministic).
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1) Define the Lighthouse gate upfront
|
||||
|
||||
Decision: treat 100s as a contract under a specific, documented run configuration.
|
||||
|
||||
- URLs: start with the production home page and any key landing/content pages that are stable and representative.
|
||||
- Variants: mobile/desktop x light/dark/high-contrast.
|
||||
- Run environment: headless Chrome in CI (no extensions), consistent throttling, at least N runs per variant with median selection.
|
||||
|
||||
Rationale: Lighthouse scores are otherwise too sensitive to environment noise to serve as a reliable build gate.
|
||||
|
||||
Alternative considered: manual Lighthouse checks. Rejected because it does not prevent regressions.
|
||||
|
||||
### 2) Fix SEO static outputs in `public/`
|
||||
|
||||
Decision: keep `robots.txt` and sitemap-related outputs as static files, but ensure correctness for production.
|
||||
|
||||
- Make the sitemap reference in `site/public/robots.txt` absolute (not a relative path).
|
||||
|
||||
Rationale: This is required for Lighthouse SEO audits and is the least invasive change.
|
||||
|
||||
Alternative considered: generate robots dynamically. Rejected as unnecessary complexity.
|
||||
|
||||
### 3) Enforce crawlable links in modal/CTA surfaces
|
||||
|
||||
Decision: if an element semantically represents navigation (internal or outbound), it must be an `<a href="...">`.
|
||||
|
||||
- For modal components, ensure the CTA is rendered as an anchor with a real destination URL when appropriate.
|
||||
- If the interaction is not navigation (e.g., close modal), use a `<button>` and ensure it is accessible.
|
||||
|
||||
Rationale: Lighthouse flags anchors without `href` as non-crawlable. Using correct semantics also improves accessibility.
|
||||
|
||||
Alternative considered: keep `<a>` without `href` and add JS click handlers. Rejected (SEO + a11y regression).
|
||||
|
||||
### 4) Process `global.css` through a production pipeline
|
||||
|
||||
Decision: stop serving the main global stylesheet as an unprocessed static asset.
|
||||
|
||||
Options:
|
||||
|
||||
- Import `global.css` from the Astro entry/layout so Vite can minify it for production builds.
|
||||
- If the site intentionally keeps a standalone global CSS file, add an explicit build step to minify it and (optionally) run a basic unused rule trimming strategy.
|
||||
|
||||
Rationale: Lighthouse currently flags unminified CSS and unused CSS rules; bundling/minification is the most straightforward fix.
|
||||
|
||||
Alternative considered: leave it in `public/` and accept lower scores. Rejected (goal is 100s).
|
||||
|
||||
### 5) Font loading strategy optimized for mobile performance
|
||||
|
||||
Decision: make font loading non-blocking and deterministic.
|
||||
|
||||
- Prefer self-hosted fonts or Astro's recommended font strategy.
|
||||
- Use `font-display: swap` and consider `preload` for critical weights.
|
||||
- Avoid unnecessary font variants.
|
||||
|
||||
Rationale: render-blocking and late text rendering negatively impact Performance and perceived load.
|
||||
|
||||
Alternative considered: keep current remote font loading. Rejected if it remains render-blocking.
|
||||
|
||||
### 6) Theme token constraints for contrast
|
||||
|
||||
Decision: fix contrast failures at the token level first, with targeted overrides only when needed.
|
||||
|
||||
- Identify failing color pairs (foreground/background) in dark mode.
|
||||
- Update tokens in `site/public/styles/global.css` so common surfaces always meet contrast expectations.
|
||||
|
||||
Rationale: token-level fixes prevent regressions across multiple components.
|
||||
|
||||
Alternative considered: per-component overrides only. Rejected as brittle.
|
||||
|
||||
### 7) CSP / Best Practices compliance
|
||||
|
||||
Decision: address Lighthouse Best Practices issues by aligning nginx headers with modern expectations without breaking the site.
|
||||
|
||||
- Keep security headers in `deploy/nginx.conf`.
|
||||
- If inline scripts/styles exist, prefer refactoring to external files to avoid unsafe CSP allowances; otherwise use a nonce/hash strategy.
|
||||
|
||||
Rationale: strong CSP improves security posture and can satisfy Lighthouse findings, but must be implemented in a way compatible with the build output.
|
||||
|
||||
Alternative considered: disable CSP to appease some checks. Rejected.
|
||||
|
||||
### 8) Images: reduce third-party variability
|
||||
|
||||
Decision: prioritize first-party control of above-the-fold images.
|
||||
|
||||
- Use Astro image tooling for locally hosted images.
|
||||
- For third-party images that block 100s (e.g., external thumbnails), prefer either self-hosting (if allowed) or avoid rendering them above the fold during initial load.
|
||||
|
||||
Rationale: image delivery and caching audits can be impossible to satisfy if key images are served from third parties with unknown caching/format behavior.
|
||||
|
||||
Alternative considered: ignore image findings. Rejected if they prevent the 100 gate.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Lighthouse instability] Scores vary across runs/environments -> Mitigation: lock run configuration and use a clean profile in CI.
|
||||
- [Build pipeline change risk] Moving `global.css` into bundling may change selector precedence -> Mitigation: diff visual output across themes and add a lightweight snapshot/DOM test where possible.
|
||||
- [CSP breakage] Tight CSP can block analytics or inline scripts -> Mitigation: inventory scripts, move to external, or add nonce/hash strategy.
|
||||
- [Third-party dependencies] External media assets may prevent 100 -> Mitigation: reduce third-party assets on gated pages or self-host where permissible.
|
||||
- [Service worker caching] SW can serve stale assets after deploy -> Mitigation: version critical assets and disable caching for SW + critical CSS, ensure SW update flow.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
- Implement fixes behind minimal, incremental commits (SEO correctness, crawlable anchors, contrast tokens, CSS pipeline/font loading, CSP).
|
||||
- Deploy with cache-busting in place so critical style updates reach clients quickly.
|
||||
- Rollback: revert to a known-good image tag/digest and confirm SW cache versioning does not pin stale assets.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Which URLs are included in the Lighthouse gate (home only vs additional pages)?
|
||||
- Are 100 scores required even when pages include third-party embeds/thumbnails, or should we scope the gate to pages that can be made first-party deterministic?
|
||||
- Should schema.org structured data be implemented for all pages or only key landing/content types?
|
||||
@@ -0,0 +1,38 @@
|
||||
## Why
|
||||
|
||||
Chrome Lighthouse runs against the production site show repeated misses across Performance, SEO, Best Practices, and (in dark theme) Accessibility. These gaps reduce technical robustness and make it hard to trust deploy-to-deploy quality.
|
||||
|
||||
We want the site to be measurably correct and fast under a repeatable, automated Lighthouse configuration, and to treat Lighthouse regressions as build-blocking.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Make SEO outputs Lighthouse-clean by fixing `robots.txt` validity (absolute sitemap URL) and ensuring key links are crawlable.
|
||||
- For better SEO, implement schema based on https://schema.org/docs/full.html
|
||||
- If it helps performance and SEO, minify relevant assets.
|
||||
- Remove non-crawlable anchor patterns (anchors without `href`) in the media modal and CTA surfaces.
|
||||
- Fix dark theme contrast failures so text and links meet WCAG intent and Lighthouse `color-contrast` passes.
|
||||
- Reduce render-blocking work in the initial load path (fonts + global stylesheet) to improve mobile Performance.
|
||||
- Reduce CSS overhead by moving `global.css` through the build/minification pipeline and trimming unused rules.
|
||||
- Eliminate forced reflow hotspots caused by synchronous layout reads/writes in scroll/resize handlers.
|
||||
- Improve image delivery (size, format, and caching) to address Lighthouse image savings and cache lifetime findings.
|
||||
- Add a deterministic Lighthouse quality gate (CI or scripted) that runs against known URLs, form factors, and themes and requires 100s in a clean environment (no extensions).
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `lighthouse-quality-gate`: Define the target Lighthouse configuration (URLs, mobile/desktop, themes, run environment) and require 100/100/100/100 with report artifacts.
|
||||
- `responsive-image-delivery`: Deliver appropriately sized images and modern formats (or controlled proxies/self-hosting) for key surfaces so image delivery audits pass consistently.
|
||||
|
||||
### Modified Capabilities
|
||||
- `seo-content-surface`: Ensure `robots.txt` is valid and sitemap references are correct for the deployed domain; ensure Lighthouse SEO crawlability audits pass.
|
||||
- `media-modal`: Ensure modal fallback and CTA links are crawlable anchors (real `href`s) and remain accessible.
|
||||
- `wcag-responsive-ui`: Tighten requirements to prevent Lighthouse accessibility regressions (contrast and keyboard/focus behavior).
|
||||
- `site-theming`: Ensure dark theme tokens never produce low-contrast text-on-light backgrounds on primary surfaces.
|
||||
- `service-worker-performance`: Ensure caching behavior does not cause stale critical assets after deploy, and align cache strategy/headers with Lighthouse expectations where feasible.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected areas include: `site/public/robots.txt`, `site/src/layouts/BaseLayout.astro`, `site/src/components/MediaModal.astro`, `site/public/styles/global.css`, `site/public/sw.js`, and `deploy/nginx.conf`.
|
||||
- This change may require build-pipeline adjustments (CSS processing/minification, image processing or controlled caching/proxying) and CI scripting to run Lighthouse deterministically.
|
||||
- Some Lighthouse findings are attributable to third-party resources (e.g., YouTube thumbnails, externally hosted podcast art) and/or local Chrome extensions; hitting a strict 100 may require reducing third-party dependence and running audits in a clean profile.
|
||||
- Coding agent may run out of context window. In that case, compact the context and retry with chunking.
|
||||
@@ -0,0 +1,46 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Deterministic Lighthouse configuration
|
||||
The project MUST define a deterministic Lighthouse run configuration that specifies:
|
||||
|
||||
- a fixed list of URLs to audit
|
||||
- mobile and desktop runs
|
||||
- theme variants: `light`, `dark`, and `high-contrast`
|
||||
- a clean run environment (no browser extensions)
|
||||
- throttling / device emulation settings
|
||||
|
||||
The configuration MUST be checked into the repository.
|
||||
|
||||
#### Scenario: Lighthouse config is version-controlled
|
||||
- **WHEN** a developer checks the repository
|
||||
- **THEN** a Lighthouse configuration file/script exists and is referenced by a documented command
|
||||
|
||||
### Requirement: Lighthouse gate enforces perfect scores
|
||||
The project MUST provide a command that runs Lighthouse for the configured URLs and variants and fails if any category score is below 100:
|
||||
|
||||
- Performance
|
||||
- Accessibility
|
||||
- Best Practices
|
||||
- SEO
|
||||
|
||||
The command MUST output the reports as build artifacts (HTML and/or JSON) to a deterministic output directory.
|
||||
|
||||
#### Scenario: Gate fails on a regression
|
||||
- **WHEN** Lighthouse runs and any audited variant scores 99 in any category
|
||||
- **THEN** the command exits non-zero and reports which URL/variant failed
|
||||
|
||||
#### Scenario: Gate produces artifacts
|
||||
- **WHEN** Lighthouse runs successfully
|
||||
- **THEN** the reports are written to the configured output directory for later inspection
|
||||
|
||||
### Requirement: Repeatable scoring rule
|
||||
The Lighthouse gate MUST define a repeatable scoring rule to reduce run-to-run noise.
|
||||
|
||||
At minimum, it MUST document one of the following:
|
||||
|
||||
- single-run with fixed throttling and clean environment, or
|
||||
- multiple runs per variant with a deterministic selection rule (e.g., median)
|
||||
|
||||
#### Scenario: Run-to-run method is documented
|
||||
- **WHEN** a developer runs the gate locally or in CI
|
||||
- **THEN** the method for selecting/reporting scores is explicitly documented
|
||||
@@ -0,0 +1,49 @@
|
||||
## MODIFIED 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
|
||||
|
||||
All modal CTAs that represent navigation MUST be implemented as crawlable anchors:
|
||||
|
||||
- Each CTA MUST be an `<a>` element with a non-empty `href` attribute.
|
||||
- The UI MUST NOT render placeholder `<a>` elements without `href` in the initial HTML.
|
||||
- If CTA destinations are not known until a user selects an item, the CTA UI MUST be rendered as non-anchor elements until the destinations are known.
|
||||
|
||||
#### 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
|
||||
|
||||
#### Scenario: Modal CTAs are crawlable anchors
|
||||
- **WHEN** the modal is present in the DOM (before any user interaction)
|
||||
- **THEN** the document contains no `<a>` elements in the modal that are missing `href`
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Embed fallback is a link only when a destination is available
|
||||
If an embed fallback is presented as a link to an external page, it MUST be an anchor with a valid `href`. If no destination is available, the fallback MUST be hidden or rendered as non-link text.
|
||||
|
||||
#### Scenario: Embed fallback does not render a non-crawlable anchor
|
||||
- **WHEN** the modal is rendered before any item selection
|
||||
- **THEN** the embed fallback is not rendered as an anchor without `href`
|
||||
@@ -0,0 +1,29 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Card thumbnails have explicit dimensions
|
||||
All card thumbnail images MUST include explicit `width` and `height` attributes matching the rendered aspect ratio.
|
||||
|
||||
#### Scenario: Thumbnail dimensions are present
|
||||
- **WHEN** a crawler or browser loads a listing surface with content cards
|
||||
- **THEN** each card thumbnail `<img>` includes `width` and `height` attributes
|
||||
|
||||
### Requirement: Above-the-fold imagery is optimized
|
||||
The site MUST ensure above-the-fold images are optimized for performance:
|
||||
|
||||
- avoid unnecessarily large image payloads for the requested viewport
|
||||
- prefer modern image formats when first-party controlled
|
||||
- avoid layout shift caused by late image dimension discovery
|
||||
|
||||
#### Scenario: Above-the-fold images do not cause layout shift
|
||||
- **WHEN** a user loads the home page on a mobile viewport
|
||||
- **THEN** the hero/top content area does not shift vertically due to image loading
|
||||
|
||||
### Requirement: Third-party image variability is controlled for the Lighthouse gate
|
||||
If a gated page depends on third-party images (e.g., external thumbnails), the gate MUST either:
|
||||
|
||||
- ensure those images do not block reaching 100s by design (e.g., not above-the-fold, lazy-loaded), or
|
||||
- provide a first-party controlled alternative for gated pages.
|
||||
|
||||
#### Scenario: Gated pages avoid third-party image volatility
|
||||
- **WHEN** Lighthouse runs against the gated URL set
|
||||
- **THEN** third-party image delivery does not prevent meeting the required scores
|
||||
@@ -0,0 +1,39 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Sitemap and robots
|
||||
The site MUST provide:
|
||||
|
||||
- `sitemap-index.xml` enumerating indexable pages (and/or referencing sitemap shards)
|
||||
- `robots.txt` that allows indexing of indexable pages
|
||||
|
||||
The sitemap MUST include the blog surface routes:
|
||||
|
||||
- `/blog`
|
||||
- blog post detail routes
|
||||
- blog page detail routes
|
||||
- blog category listing routes
|
||||
|
||||
`robots.txt` MUST reference the sitemap using an absolute URL for the production domain.
|
||||
|
||||
#### Scenario: Sitemap index is available
|
||||
- **WHEN** a crawler requests `/sitemap-index.xml`
|
||||
- **THEN** the server returns an XML sitemap index (or sitemap) listing `/`, `/videos`, `/podcast`, `/about`, and `/blog`
|
||||
|
||||
#### Scenario: Blog URLs appear in sitemap
|
||||
- **WHEN** WordPress content is available in the cache at build time
|
||||
- **THEN** the generated sitemap includes the blog detail URLs for those items
|
||||
|
||||
#### Scenario: Robots references sitemap with absolute URL
|
||||
- **WHEN** a crawler requests `/robots.txt`
|
||||
- **THEN** the response contains a `Sitemap:` line with an absolute URL to `/sitemap-index.xml`
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Organization and website structured data
|
||||
The home page SHOULD include JSON-LD structured data for the site and its owner/organization.
|
||||
|
||||
If present, the JSON-LD MUST be valid JSON and MUST use a recognized schema type.
|
||||
|
||||
#### Scenario: Home page includes valid JSON-LD
|
||||
- **WHEN** a crawler requests `/`
|
||||
- **THEN** the HTML contains a JSON-LD script tag that parses as valid JSON
|
||||
@@ -0,0 +1,17 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Critical assets do not remain stale after deploy
|
||||
The service worker and server caching strategy MUST ensure critical shell assets (including the global stylesheet and service worker script) do not remain stale across deploys.
|
||||
|
||||
The implementation MUST include at least one cache-busting mechanism for critical assets, such as:
|
||||
|
||||
- content-hashed asset filenames, or
|
||||
- an asset version query suffix that changes per deploy
|
||||
|
||||
#### Scenario: New deploy updates critical CSS
|
||||
- **WHEN** a new deploy is released and a returning user loads the site
|
||||
- **THEN** the user receives the updated global stylesheet without requiring a manual hard refresh
|
||||
|
||||
#### Scenario: Service worker updates predictably
|
||||
- **WHEN** a new deploy is released
|
||||
- **THEN** the browser can retrieve the updated service worker script and activate it without being pinned by long-lived caches
|
||||
@@ -0,0 +1,41 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Theme tokens meet contrast intent
|
||||
For each supported theme (`dark`, `light`, `high-contrast`), the theme token pairs used for primary text and primary surfaces MUST meet WCAG 2.2 AA contrast intent.
|
||||
|
||||
At minimum:
|
||||
|
||||
- primary body text on the primary background MUST be high-contrast
|
||||
- link text on the primary background MUST be distinguishable and meet contrast intent
|
||||
- secondary labels on the primary background MUST remain readable
|
||||
|
||||
#### Scenario: Dark theme text is readable
|
||||
- **WHEN** `data-theme="dark"` is active
|
||||
- **THEN** primary text remains readable against primary surfaces without low-contrast combinations
|
||||
|
||||
#### Scenario: Dark theme links are readable
|
||||
- **WHEN** `data-theme="dark"` is active
|
||||
- **THEN** links in common surfaces are readable against their background
|
||||
|
||||
## MODIFIED 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
|
||||
@@ -0,0 +1,21 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Interactive elements use correct semantics
|
||||
Interactive elements MUST use correct semantic elements:
|
||||
|
||||
- Navigation MUST use anchors (`<a>`) with valid `href`.
|
||||
- Actions that do not navigate MUST use `<button>`.
|
||||
|
||||
The site MUST NOT render anchor elements that lack an `href` attribute.
|
||||
|
||||
#### Scenario: Modal triggers are buttons
|
||||
- **WHEN** a content card opens the media modal
|
||||
- **THEN** the card is a `<button>` element and is keyboard operable
|
||||
|
||||
#### Scenario: Navigation CTAs are anchors
|
||||
- **WHEN** a user sees a CTA that navigates to another page
|
||||
- **THEN** the CTA is an `<a>` element with a valid `href`
|
||||
|
||||
#### Scenario: No non-crawlable anchors exist
|
||||
- **WHEN** a crawler inspects the rendered HTML
|
||||
- **THEN** there are no `<a>` elements without `href`
|
||||
@@ -0,0 +1,52 @@
|
||||
## 1. Baseline And Repro
|
||||
|
||||
- [x] 1.1 Add a repeatable Lighthouse run command (local) that outputs reports for mobile/desktop x light/dark/high-contrast
|
||||
- [x] 1.2 Document the Lighthouse run environment requirements (clean profile / no extensions) and scoring rule (single vs median)
|
||||
- [x] 1.3 Capture current failing audits in a checked-in note (from the provided JSON reports) to validate fixes one-by-one
|
||||
|
||||
## 2. SEO Correctness
|
||||
|
||||
- [x] 2.1 Update `site/public/robots.txt` to reference the sitemap with an absolute URL to `/sitemap-index.xml`
|
||||
- [x] 2.2 Verify `site/astro.config.mjs` has `site` set correctly in production via `PUBLIC_SITE_URL` and that sitemap output is present after build
|
||||
- [x] 2.3 Add/verify JSON-LD structured data on the all pages (valid JSON, recognized schema type)
|
||||
|
||||
## 3. Crawlable Anchors (Media Modal)
|
||||
|
||||
- [x] 3.1 Update `site/src/components/MediaModal.astro` so no modal anchors are rendered without `href` in the initial HTML
|
||||
- [x] 3.2 Ensure modal CTAs that navigate are always `<a href="...">` and actions that do not navigate are `<button>`
|
||||
- [x] 3.3 Re-run Lighthouse SEO audit to confirm `crawlable-anchors` passes
|
||||
|
||||
## 4. CSS Processing And Render Blocking
|
||||
|
||||
- [x] 4.1 Move `site/public/styles/global.css` into the Astro/Vite build pipeline (so production output is minified and cache-busted)
|
||||
- [x] 4.2 Update `site/src/layouts/BaseLayout.astro` to load the built CSS output and remove any no-longer-needed manual cache-busting for CSS
|
||||
- [x] 4.3 Adjust `site/public/sw.js` shell precache list to match the new CSS delivery strategy
|
||||
- [x] 4.4 Confirm Lighthouse no longer reports `unminified-css` and materially reduces `unused-css-rules` for the primary pages
|
||||
|
||||
## 5. Fonts
|
||||
|
||||
- [x] 5.1 Replace render-blocking Google Fonts with a self-hosted font strategy (woff2 + `font-display`)
|
||||
- [x] 5.2 Preload only the critical font files/weights needed for above-the-fold rendering
|
||||
- [x] 5.3 Re-run Lighthouse to confirm improved `render-blocking` findings and LCP trend
|
||||
|
||||
## 6. Images
|
||||
|
||||
- [x] 6.1 Add explicit `width`/`height` for card thumbnail images (and any other frequently used images missing dimensions)
|
||||
- [x] 6.2 Ensure above-the-fold imagery on gated pages is deterministic and does not rely on third-party delivery for a perfect Lighthouse gate
|
||||
- [x] 6.3 Re-run Lighthouse to validate reduced CLS and improved image-related savings audits
|
||||
|
||||
## 7. Service Worker / Cache Robustness
|
||||
|
||||
- [x] 7.1 Verify critical asset cache-busting is effective across deploys (SW + global CSS) and does not pin stale styles
|
||||
- [x] 7.2 Confirm nginx cache headers are correct for `/sw.js` (no-store) and any critical CSS entrypoint
|
||||
|
||||
## 8. Security Headers (Best Practices)
|
||||
|
||||
- [x] 8.1 Add baseline security headers in `deploy/nginx.conf` (CSP, nosniff, frame-ancestors / X-Frame-Options, referrer-policy, permissions-policy)
|
||||
- [x] 8.2 Choose a CSP tier (moderate vs hash-based) and ensure the site functions with no CSP console violations in the normal path
|
||||
- [x] 8.3 Re-run Lighthouse Best Practices to confirm CSP-related inspector issues are resolved
|
||||
|
||||
## 9. CI Quality Gate
|
||||
|
||||
- [x] 9.1 Add a CI job that runs the Lighthouse command and uploads reports as artifacts
|
||||
- [x] 9.2 Make CI fail if any configured URL/variant scores below 100 in any category
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-10
|
||||
109
openspec/changes/archive/2026-02-10-reduce-bounce-rate/design.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,51 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Custom event tracking
|
||||
When Umami is enabled, the site MUST support custom event emission for:
|
||||
- `cta_click`
|
||||
- `outbound_click`
|
||||
- `media_preview`
|
||||
- `media_preview_close`
|
||||
- a general click interaction event for all instrumented clickable items (per the site tracking taxonomy)
|
||||
|
||||
Each emitted event MUST include enough properties to segment reports by platform and placement when applicable.
|
||||
|
||||
All tracked clickable items MUST emit events with a unique, consistent set of data elements as defined by the site tracking taxonomy, including at minimum `target_id` and `placement`.
|
||||
|
||||
The site MUST instrument tracked clickables using Umami's supported Track Events data-attribute method:
|
||||
- `data-umami-event="<event-name>"`
|
||||
- optional event data using `data-umami-event-*`
|
||||
|
||||
For interactions that are triggered programmatically (e.g., modal close events where the close method must be recorded), the site MAY use Umami's JavaScript API (`umami.track()`) instead of data attributes when data attributes cannot express the required properties.
|
||||
|
||||
For content-related links (clickables representing a specific piece of content), the site MUST also provide the following Umami event data attributes:
|
||||
- `data-umami-event-title`
|
||||
- `data-umami-event-type`
|
||||
|
||||
#### Scenario: Emit outbound click event
|
||||
- **WHEN** a user clicks a non-CTA outbound link from the homepage
|
||||
- **THEN** the system emits an `outbound_click` event with a property identifying the destination domain
|
||||
|
||||
#### Scenario: Emit general click event for any clickable
|
||||
- **WHEN** a user clicks an instrumented navigation link
|
||||
- **THEN** the system emits a click interaction event with `target_id` and `placement`
|
||||
|
||||
#### Scenario: Content click includes title and type
|
||||
- **WHEN** a user clicks an instrumented content link (video, podcast episode, blog post/page)
|
||||
- **THEN** the emitted Umami event includes `title` and `type` properties via `data-umami-event-*` attributes
|
||||
|
||||
#### Scenario: Uninstrumented clicks do not break the page
|
||||
- **WHEN** a user clicks an element with no tracking metadata
|
||||
- **THEN** the system does not throw and navigation/interaction proceeds normally
|
||||
|
||||
#### Scenario: Media preview event emitted on card click
|
||||
- **WHEN** a user clicks a video or podcast content card that opens a media modal
|
||||
- **THEN** the system emits a `media_preview` event with `target_id`, `placement`, `title`, `type`, and `source`
|
||||
|
||||
#### Scenario: Media preview close event emitted
|
||||
- **WHEN** a user closes the media modal
|
||||
- **THEN** the system emits a `media_preview_close` event with the `target_id` of the content that was previewed and the `close_method` used
|
||||
|
||||
#### Scenario: Modal CTA click emitted
|
||||
- **WHEN** a user clicks a CTA inside the media modal (e.g., "View on YouTube")
|
||||
- **THEN** the system emits a `cta_click` event with `target_id`, `placement=media_modal`, `platform`, and `target_url`
|
||||
@@ -0,0 +1,45 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Standard card information architecture
|
||||
All content cards rendered by the site MUST use a standardized layout so cards across different surfaces look consistent.
|
||||
|
||||
The standard card layout MUST be:
|
||||
- featured image displayed prominently at the top (when available)
|
||||
- title
|
||||
- summary/excerpt text, trimmed to a fixed maximum length
|
||||
- footer row showing:
|
||||
- publish date on the left
|
||||
- views when available (if omitted, the footer MUST still render cleanly)
|
||||
- the content source label (e.g., `youtube`, `podcast`, `blog`)
|
||||
|
||||
If a field is not available (for example, views for some sources), the card MUST still render cleanly with that field omitted.
|
||||
|
||||
For content cards with source `youtube` or `podcast`, the card MUST render as a clickable element that opens a media modal dialog instead of navigating to an external URL. The card MUST NOT render as an outbound `<a>` link for these sources.
|
||||
|
||||
For content cards with other sources (e.g., `blog`, `instagram`), the card MUST continue to render as a navigational link (the existing behavior).
|
||||
|
||||
The card element for modal-trigger cards MUST carry the content item's data (id, source, url, title, summary, publishedAt, thumbnailUrl, views) as `data-*` attributes so the modal script can access them.
|
||||
|
||||
#### Scenario: Card renders with all fields
|
||||
- **WHEN** a content item has an image, title, summary, publish date, views, and source
|
||||
- **THEN** the card renders those fields in the standard card layout order
|
||||
|
||||
#### Scenario: Card renders without views
|
||||
- **WHEN** a content item has no views data
|
||||
- **THEN** the card renders the footer bar with date + source and omits views without breaking the layout
|
||||
|
||||
#### Scenario: Card renders without featured image
|
||||
- **WHEN** a content item has no featured image
|
||||
- **THEN** the card renders a placeholder media area and still renders the remaining fields
|
||||
|
||||
#### Scenario: YouTube video card opens modal
|
||||
- **WHEN** a user clicks a content card with source `youtube`
|
||||
- **THEN** a media modal dialog opens with the video's embedded player and metadata instead of navigating to YouTube
|
||||
|
||||
#### Scenario: Podcast card opens modal
|
||||
- **WHEN** a user clicks a content card with source `podcast`
|
||||
- **THEN** a media modal dialog opens with the episode's embedded player (or metadata link) instead of navigating to the podcast platform
|
||||
|
||||
#### Scenario: Blog card still navigates
|
||||
- **WHEN** a user clicks a content card with source `blog`
|
||||
- **THEN** the card navigates to the blog post as an internal link (existing behavior, unaffected)
|
||||
@@ -0,0 +1,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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-10
|
||||
@@ -0,0 +1,52 @@
|
||||
## Context
|
||||
|
||||
This change introduces a Service Worker to improve perceived load time and reduce network usage on repeat visits by caching critical assets in the browser.
|
||||
|
||||
The site is a static Astro build. That means:
|
||||
- The Service Worker should live at the site root (`/sw.js`) so it can control all routes.
|
||||
- Navigations (HTML documents) should not be cached in a way that causes indefinite staleness after new deploys.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Improve repeat-visit performance by pre-caching the critical site shell assets.
|
||||
- Add runtime caching for media assets (images) with bounded storage usage.
|
||||
- Ensure safe update behavior: cache versioning and cleanup on activate.
|
||||
- Keep local development predictable by not registering the Service Worker in dev by default.
|
||||
|
||||
**Non-Goals:**
|
||||
- Full offline-first experience for all routes/content.
|
||||
- Background sync, push notifications, or complex offline fallbacks.
|
||||
- Server-side caching (handled by separate changes, if desired).
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Implement a lightweight, custom Service Worker (no Workbox)**
|
||||
Rationale: The project already outputs static assets and the needed caching strategies are straightforward. A small custom `/sw.js` avoids adding a build-time dependency and keeps behavior explicit.
|
||||
Alternatives considered:
|
||||
- Workbox: powerful, but adds dependency surface area and build configuration overhead.
|
||||
|
||||
2. **Cache strategy by request type**
|
||||
Rationale: Different resources have different freshness requirements.
|
||||
- Navigations (HTML documents): **Network-first**, fallback to cache on failure. This minimizes stale HTML risks while still helping resiliency.
|
||||
- Static shell assets (CSS/JS/fonts/icons): **Pre-cache** on install and serve from cache for speed.
|
||||
- Images/media: **Cache-first** with a size bound and eviction to avoid unbounded storage.
|
||||
|
||||
3. **Versioned caches + activation cleanup**
|
||||
Rationale: Static sites frequently redeploy; versioning ensures updates can be picked up and old assets are not served after deploy. On activate, the SW deletes prior version caches.
|
||||
Implementation approach:
|
||||
- Use cache names like `shell-v<version>` and `media-v<version>`.
|
||||
- Update the version string on build (initially a constant; later can be automated).
|
||||
|
||||
4. **Disable SW registration in development by default**
|
||||
Rationale: Service worker caching can confuse local iteration and cause stale assets during development.
|
||||
Implementation approach:
|
||||
- Register SW only when `import.meta.env.PROD` is true (Astro build-time flag) or an explicit runtime guard is met.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[Stale or broken assets after deploy]** → Use versioned caches and delete old caches during activation. Prefer network-first for navigations.
|
||||
- **[Over-caching HTML causes outdated content]** → Do not use cache-first for navigation; do not pre-cache HTML pages.
|
||||
- **[Storage growth due to images]** → Enforce a max-entry limit with eviction for media cache.
|
||||
- **[Browser compatibility gaps]** → Service worker is progressive enhancement; site must still function without it.
|
||||
|
||||