Compare commits
2 commits
94d8c4b43e
...
21042ec133
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21042ec133 | ||
|
|
0b91798c96 |
4 changed files with 377 additions and 73 deletions
187
AGENTS.md
Normal file
187
AGENTS.md
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
This file provides guidance for AI coding agents working in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Top 500 Albums analysis with data from Rolling Stone (2020) and Wikipedia (2023). Includes Python data processing scripts, an interactive web interface (vanilla HTML/CSS/JS), and album cover artwork from the iTunes API.
|
||||||
|
|
||||||
|
## Build/Lint/Test Commands
|
||||||
|
|
||||||
|
**No formal build system.** All Python scripts use only the standard library.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run Python scripts directly
|
||||||
|
python scripts/download_all_covers.py
|
||||||
|
python scripts/compare_top500_albums.py
|
||||||
|
|
||||||
|
# Local dev server (required for CORS)
|
||||||
|
python -m http.server 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
**No tests or linting configured.** Manual testing only - run scripts and verify output, open website and check functionality.
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Module docstring explaining the script's purpose."""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
# Constants: UPPER_SNAKE_CASE
|
||||||
|
MAX_RETRIES = 3
|
||||||
|
|
||||||
|
# Functions/variables: snake_case
|
||||||
|
def normalize_text(text: str) -> str:
|
||||||
|
"""Normalize text for comparison."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
albums_2020 = {}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key patterns:**
|
||||||
|
- Always use shebang: `#!/usr/bin/env python3`
|
||||||
|
- Include module-level docstring
|
||||||
|
- Standard library only - no external dependencies
|
||||||
|
- Type hints for function signatures
|
||||||
|
- Use `encoding='utf-8'` and `newline=''` for CSV operations
|
||||||
|
|
||||||
|
**Error handling:**
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=15) as response:
|
||||||
|
data = json.loads(response.read().decode())
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Global variables at top
|
||||||
|
let albumsData = [];
|
||||||
|
const itemsPerPage = 50;
|
||||||
|
|
||||||
|
// DOM refs cached on load
|
||||||
|
const albumsGrid = document.getElementById('albumsGrid');
|
||||||
|
|
||||||
|
// Initialize on DOMContentLoaded
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
loadData();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Async/await for fetch
|
||||||
|
async function loadAlbumsData() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('data.csv');
|
||||||
|
if (!response.ok) throw new Error('Failed');
|
||||||
|
// ...
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key patterns:**
|
||||||
|
- Variables/functions: camelCase
|
||||||
|
- Use `console.warn()` for non-critical failures
|
||||||
|
- Vanilla JS only - no frameworks
|
||||||
|
|
||||||
|
### HTML/CSS
|
||||||
|
|
||||||
|
- Semantic HTML5 elements
|
||||||
|
- CSS custom properties for theming
|
||||||
|
- Responsive design with Grid/Flexbox
|
||||||
|
|
||||||
|
## Accessibility Requirements
|
||||||
|
|
||||||
|
All UI changes must follow WCAG 2.1 AA guidelines:
|
||||||
|
|
||||||
|
**Keyboard Navigation:**
|
||||||
|
- All interactive elements must be keyboard accessible
|
||||||
|
- Use `tabindex` appropriately (0 for focusable, -1 for programmatic focus)
|
||||||
|
- Implement visible focus indicators (`:focus-visible` with `outline`)
|
||||||
|
- Support skip links for main content
|
||||||
|
|
||||||
|
**Screen Readers:**
|
||||||
|
- Use semantic HTML (`<button>`, `<nav>`, `<main>`, `<article>`)
|
||||||
|
- Add ARIA labels for icon-only buttons: `aria-label="Share album"`
|
||||||
|
- Use `.sr-only` class for screen-reader-only text
|
||||||
|
- Ensure proper heading hierarchy (`h1` > `h2` > `h3`)
|
||||||
|
|
||||||
|
**Color & Contrast:**
|
||||||
|
- Minimum 4.5:1 contrast ratio for normal text
|
||||||
|
- Minimum 3:1 for large text and UI components
|
||||||
|
- Never use color alone to convey information
|
||||||
|
|
||||||
|
**Motion & Animations:**
|
||||||
|
- Respect `prefers-reduced-motion` media query
|
||||||
|
- Avoid layout shifts on hover (no `transform: translateY()` on cards)
|
||||||
|
|
||||||
|
**Focus Management:**
|
||||||
|
- Trap focus in modals/dialogs
|
||||||
|
- Return focus to trigger element when closing overlays
|
||||||
|
|
||||||
|
**Existing Patterns:**
|
||||||
|
```css
|
||||||
|
/* Screen reader only */
|
||||||
|
.sr-only { position: absolute; width: 1px; height: 1px; ... }
|
||||||
|
|
||||||
|
/* Focus indicators */
|
||||||
|
button:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* Reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
top500albums/
|
||||||
|
├── index.html, script.js, styles.css # Website
|
||||||
|
├── *.csv, *.json # Data files
|
||||||
|
├── covers/ # Album artwork (500 images)
|
||||||
|
└── scripts/ # Python utilities (36 scripts)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Files
|
||||||
|
|
||||||
|
| File | Notes |
|
||||||
|
|------|-------|
|
||||||
|
| `rolling_stone_top_500_albums_2020.csv` | Title case columns, ranks in reverse order |
|
||||||
|
| `wikipedia_top_500_albums.csv` | Lowercase columns (`rank`, `artist`, `album`) |
|
||||||
|
| `top_500_albums_2023.csv` | Combined comparison file |
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
**CSV column naming:**
|
||||||
|
- Rolling Stone/Combined: Title case (`Rank`, `Artist`, `Album`)
|
||||||
|
- Wikipedia: lowercase (`rank`, `artist`, `album`)
|
||||||
|
|
||||||
|
**Album matching:** Uses `difflib` for fuzzy matching variations like "The Beatles" vs "Beatles"
|
||||||
|
|
||||||
|
**API rate limiting:** `time.sleep(1.2)` between requests
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
**New Python script:**
|
||||||
|
1. Create in `scripts/`, add shebang + docstring
|
||||||
|
2. Standard library only
|
||||||
|
3. Include `if __name__ == "__main__":` block
|
||||||
|
|
||||||
|
**Working with album data:**
|
||||||
|
```python
|
||||||
|
import csv
|
||||||
|
with open('top_500_albums_2023.csv', 'r', encoding='utf-8') as f:
|
||||||
|
for row in csv.DictReader(f):
|
||||||
|
rank, artist, album = int(row['Rank']), row['Artist'], row['Album']
|
||||||
|
status = row['Status'] # "New in 2023", "+10", "-5", "No change"
|
||||||
|
```
|
||||||
47
index.html
47
index.html
|
|
@ -10,13 +10,15 @@
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<a href="#albumsGrid" class="skip-link">Skip to albums</a>
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<p class="supertitle">Rolling Stone Magazine's</p>
|
<p class="supertitle">Rolling Stone Magazine's</p>
|
||||||
<h1 class="title">Top 500 Albums of All Time</h1>
|
<h1 class="title">Top 500 Albums of All Time</h1>
|
||||||
<p class="subtitle">The Greatest Albums Ever Made - 2023 Edition</p>
|
<p class="subtitle">The Greatest Albums Ever Made - 2023 Edition</p>
|
||||||
<div class="header-theme-selector">
|
<div class="header-theme-selector">
|
||||||
<select id="themeSelect" class="theme-select-header" title="Choose theme">
|
<label for="themeSelect" class="sr-only">Color theme</label>
|
||||||
|
<select id="themeSelect" class="theme-select-header" aria-label="Color theme">
|
||||||
<option value="">Gruvbox</option>
|
<option value="">Gruvbox</option>
|
||||||
<option value="purple">Basic Blue</option>
|
<option value="purple">Basic Blue</option>
|
||||||
<option value="dark">Dark</option>
|
<option value="dark">Dark</option>
|
||||||
|
|
@ -34,9 +36,10 @@
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
|
<label for="searchInput" class="sr-only">Search albums and artists</label>
|
||||||
<input type="text" id="searchInput" placeholder="Search albums, artists..." class="search-input">
|
<input type="text" id="searchInput" placeholder="Search albums, artists..." class="search-input">
|
||||||
<button id="searchButton" class="search-button">
|
<button id="searchButton" class="search-button" aria-label="Search">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<circle cx="11" cy="11" r="8"></circle>
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
<path d="m21 21-4.35-4.35"></path>
|
<path d="m21 21-4.35-4.35"></path>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -44,6 +47,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
|
<label for="statusFilter" class="sr-only">Filter by status</label>
|
||||||
<select id="statusFilter" class="filter-select">
|
<select id="statusFilter" class="filter-select">
|
||||||
<option value="">All Albums</option>
|
<option value="">All Albums</option>
|
||||||
<option value="New in 2023">New in 2023</option>
|
<option value="New in 2023">New in 2023</option>
|
||||||
|
|
@ -53,6 +57,7 @@
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<div class="sort-controls">
|
<div class="sort-controls">
|
||||||
|
<label for="sortBy" class="sr-only">Sort albums by</label>
|
||||||
<select id="sortBy" class="filter-select">
|
<select id="sortBy" class="filter-select">
|
||||||
<option value="rank">Sort by Rank</option>
|
<option value="rank">Sort by Rank</option>
|
||||||
<option value="artist">Sort by Artist</option>
|
<option value="artist">Sort by Artist</option>
|
||||||
|
|
@ -60,8 +65,8 @@
|
||||||
<option value="year">Sort by Year</option>
|
<option value="year">Sort by Year</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<button id="reverseButton" class="reverse-button" title="Reverse order">
|
<button id="reverseButton" class="reverse-button" aria-label="Reverse sort order" aria-pressed="false">
|
||||||
<span id="reverseIcon">
|
<span id="reverseIcon" aria-hidden="true">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M3 6h18M3 12h18M3 18h18"></path>
|
<path d="M3 6h18M3 12h18M3 18h18"></path>
|
||||||
<path d="M21 6l-2-2-2 2M21 18l-2 2-2 2"></path>
|
<path d="M21 6l-2-2-2 2M21 18l-2 2-2 2"></path>
|
||||||
|
|
@ -74,41 +79,45 @@
|
||||||
|
|
||||||
<div class="bookmark-controls">
|
<div class="bookmark-controls">
|
||||||
<div class="jump-to-rank">
|
<div class="jump-to-rank">
|
||||||
|
<label for="jumpToRank" class="sr-only">Jump to rank number</label>
|
||||||
<input type="number" id="jumpToRank" placeholder="Jump to..." min="1" max="500" class="rank-input">
|
<input type="number" id="jumpToRank" placeholder="Jump to..." min="1" max="500" class="rank-input">
|
||||||
<button id="jumpButton" class="jump-button" title="Jump to rank">Go</button>
|
<button id="jumpButton" class="jump-button" aria-label="Jump to rank">Go</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats" id="stats">
|
<div class="stats" id="stats" role="region" aria-label="Album statistics">
|
||||||
<div class="stat-item">
|
<button type="button" class="stat-item" data-filter="">
|
||||||
<span class="stat-number" id="totalAlbums">500</span>
|
<span class="stat-number" id="totalAlbums">500</span>
|
||||||
<span class="stat-label">Total Albums</span>
|
<span class="stat-label">Total Albums</span>
|
||||||
</div>
|
</button>
|
||||||
<div class="stat-item">
|
<button type="button" class="stat-item" data-filter="New in 2023">
|
||||||
<span class="stat-number" id="newAlbums">192</span>
|
<span class="stat-number" id="newAlbums">192</span>
|
||||||
<span class="stat-label">New in 2023</span>
|
<span class="stat-label">New in 2023</span>
|
||||||
</div>
|
</button>
|
||||||
<div class="stat-item">
|
<button type="button" class="stat-item" data-filter="improved">
|
||||||
<span class="stat-number" id="improvedAlbums">164</span>
|
<span class="stat-number" id="improvedAlbums">164</span>
|
||||||
<span class="stat-label">Improved</span>
|
<span class="stat-label">Improved</span>
|
||||||
</div>
|
</button>
|
||||||
<div class="stat-item">
|
<button type="button" class="stat-item" data-filter="dropped">
|
||||||
<span class="stat-number" id="droppedAlbums">113</span>
|
<span class="stat-number" id="droppedAlbums">113</span>
|
||||||
<span class="stat-label">Dropped</span>
|
<span class="stat-label">Dropped</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="albums-grid" id="albumsGrid">
|
<!-- Live region for screen reader announcements -->
|
||||||
|
<div aria-live="polite" aria-atomic="true" class="sr-only" id="resultsAnnouncement"></div>
|
||||||
|
|
||||||
|
<div class="albums-grid" id="albumsGrid" role="region" aria-label="Album list" tabindex="-1">
|
||||||
<!-- Albums will be loaded here by JavaScript -->
|
<!-- Albums will be loaded here by JavaScript -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="loading" id="loading">
|
<div class="loading" id="loading" role="status" aria-live="polite">
|
||||||
<div class="loading-spinner"></div>
|
<div class="loading-spinner" aria-hidden="true"></div>
|
||||||
<p>Loading albums...</p>
|
<p>Loading albums...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="error" id="error" style="display: none;">
|
<div class="error" id="error" role="alert" style="display: none;">
|
||||||
<p>Error loading albums. Please try again later.</p>
|
<p>Error loading albums. Please try again later.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
77
script.js
77
script.js
|
|
@ -73,34 +73,14 @@ function setupEventListeners() {
|
||||||
|
|
||||||
// Setup click handlers for stats cards
|
// Setup click handlers for stats cards
|
||||||
function setupStatsClickHandlers() {
|
function setupStatsClickHandlers() {
|
||||||
const totalAlbumsCard = document.querySelector('.stat-item:has(#totalAlbums)') ||
|
// Stats cards are now buttons with data-filter attributes
|
||||||
document.querySelector('#totalAlbums').closest('.stat-item');
|
const statButtons = document.querySelectorAll('.stat-item[data-filter]');
|
||||||
const newAlbumsCard = document.querySelector('.stat-item:has(#newAlbums)') ||
|
statButtons.forEach(button => {
|
||||||
document.querySelector('#newAlbums').closest('.stat-item');
|
button.addEventListener('click', () => {
|
||||||
const improvedAlbumsCard = document.querySelector('.stat-item:has(#improvedAlbums)') ||
|
const filterValue = button.getAttribute('data-filter');
|
||||||
document.querySelector('#improvedAlbums').closest('.stat-item');
|
handleStatsCardClick(filterValue);
|
||||||
const droppedAlbumsCard = document.querySelector('.stat-item:has(#droppedAlbums)') ||
|
});
|
||||||
document.querySelector('#droppedAlbums').closest('.stat-item');
|
});
|
||||||
|
|
||||||
if (totalAlbumsCard) {
|
|
||||||
totalAlbumsCard.addEventListener('click', () => handleStatsCardClick(''));
|
|
||||||
totalAlbumsCard.style.cursor = 'pointer';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newAlbumsCard) {
|
|
||||||
newAlbumsCard.addEventListener('click', () => handleStatsCardClick('New in 2023'));
|
|
||||||
newAlbumsCard.style.cursor = 'pointer';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (improvedAlbumsCard) {
|
|
||||||
improvedAlbumsCard.addEventListener('click', () => handleStatsCardClick('improved'));
|
|
||||||
improvedAlbumsCard.style.cursor = 'pointer';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (droppedAlbumsCard) {
|
|
||||||
droppedAlbumsCard.addEventListener('click', () => handleStatsCardClick('dropped'));
|
|
||||||
droppedAlbumsCard.style.cursor = 'pointer';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle stats card clicks
|
// Handle stats card clicks
|
||||||
|
|
@ -235,6 +215,11 @@ function renderAlbums() {
|
||||||
|
|
||||||
if (currentPage === 1) {
|
if (currentPage === 1) {
|
||||||
albumsGrid.innerHTML = '';
|
albumsGrid.innerHTML = '';
|
||||||
|
// Announce results to screen readers
|
||||||
|
const announcement = document.getElementById('resultsAnnouncement');
|
||||||
|
if (announcement) {
|
||||||
|
announcement.textContent = `Showing ${filteredData.length} albums`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
albumsToShow.slice(startIndex).forEach(album => {
|
albumsToShow.slice(startIndex).forEach(album => {
|
||||||
|
|
@ -288,34 +273,34 @@ function createAlbumCard(album) {
|
||||||
const nextAlbum = hasNext ? filteredData[currentIndex + 1] : null;
|
const nextAlbum = hasNext ? filteredData[currentIndex + 1] : null;
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="album-header-grid">
|
<article class="album-header-grid">
|
||||||
<div class="album-rank">#${album.Rank}</div>
|
<div class="album-rank" aria-label="Rank ${album.Rank}">#${album.Rank}</div>
|
||||||
<div class="album-details">
|
<div class="album-details">
|
||||||
<div class="album-title-row">
|
<div class="album-title-row">
|
||||||
<div class="album-title">${escapeHtml(album.Album)}</div>
|
<h2 class="album-title">${escapeHtml(album.Album)}</h2>
|
||||||
<button class="album-share album-share-desktop" title="Share this album" data-rank="${album.Rank}">
|
<button class="album-share album-share-desktop" aria-label="Share ${escapeHtml(album.Album)}" data-rank="${album.Rank}">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"></path>
|
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"></path>
|
||||||
<polyline points="16,6 12,2 8,6"></polyline>
|
<polyline points="16,6 12,2 8,6"></polyline>
|
||||||
<line x1="12" y1="2" x2="12" y2="15"></line>
|
<line x1="12" y1="2" x2="12" y2="15"></line>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="album-artist">${escapeHtml(album.Artist)}</div>
|
<p class="album-artist">${escapeHtml(album.Artist)}</p>
|
||||||
${album.Info ? `<div class="album-info">${escapeHtml(album.Info)}</div>` : ''}
|
${album.Info ? `<p class="album-info">${escapeHtml(album.Info)}</p>` : ''}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</article>
|
||||||
<div class="album-cover">
|
<div class="album-cover">
|
||||||
${coverImagePath ?
|
${coverImagePath ?
|
||||||
`<img src="${coverImagePath}" alt="${escapeHtml(album.Album)} by ${escapeHtml(album.Artist)}" class="album-cover-image" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
`<img src="${coverImagePath}" alt="${escapeHtml(album.Album)} by ${escapeHtml(album.Artist)}" class="album-cover-image" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||||
<div class="album-cover-icon" style="display: none;">🎵</div>` :
|
<div class="album-cover-icon" style="display: none;" aria-hidden="true">🎵</div>` :
|
||||||
`<div class="album-cover-icon">🎵</div>`
|
`<div class="album-cover-icon" aria-hidden="true">🎵</div>`
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="album-status-row">
|
<div class="album-status-row">
|
||||||
<div class="album-status ${statusClass}">${statusText}</div>
|
<div class="album-status ${statusClass}">${statusText}</div>
|
||||||
<button class="album-share album-share-mobile" title="Share this album" data-rank="${album.Rank}">
|
<button class="album-share album-share-mobile" aria-label="Share ${escapeHtml(album.Album)}" data-rank="${album.Rank}">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"></path>
|
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"></path>
|
||||||
<polyline points="16,6 12,2 8,6"></polyline>
|
<polyline points="16,6 12,2 8,6"></polyline>
|
||||||
<line x1="12" y1="2" x2="12" y2="15"></line>
|
<line x1="12" y1="2" x2="12" y2="15"></line>
|
||||||
|
|
@ -326,18 +311,18 @@ function createAlbumCard(album) {
|
||||||
<div class="album-links">
|
<div class="album-links">
|
||||||
<div class="album-links-row">
|
<div class="album-links-row">
|
||||||
<a href="${generateWikipediaUrl(album.Album, album.Artist)}" target="_blank" rel="noopener noreferrer" class="wikipedia-link">
|
<a href="${generateWikipediaUrl(album.Album, album.Artist)}" target="_blank" rel="noopener noreferrer" class="wikipedia-link">
|
||||||
View on Wikipedia →
|
View on Wikipedia <span aria-hidden="true">→</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="${generateSpotifyUrl(album.Album, album.Artist)}" target="_blank" rel="noopener noreferrer" class="spotify-link">
|
<a href="${generateSpotifyUrl(album.Album, album.Artist)}" target="_blank" rel="noopener noreferrer" class="spotify-link">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="margin-right: 0.5rem;">
|
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="margin-right: 0.5rem;">
|
||||||
<path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.84-.179-.959-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.361 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.42 1.56-.299.421-1.02.599-1.559.3z"/>
|
<path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.84-.179-.959-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.361 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.42 1.56-.299.421-1.02.599-1.559.3z"/>
|
||||||
</svg>
|
</svg>
|
||||||
Listen on Spotify
|
Listen on Spotify
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
${hasNext ? `
|
${hasNext ? `
|
||||||
<button class="next-album-link next-album-link-wide" data-rank="${album.Rank}" title="Go to next album (#${nextAlbum.Rank})">
|
<button class="next-album-link next-album-link-wide" data-rank="${album.Rank}" aria-label="Go to next album, number ${nextAlbum.Rank}">
|
||||||
Next Album →
|
Next Album <span aria-hidden="true">→</span>
|
||||||
</button>
|
</button>
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -466,8 +451,9 @@ function handleSort(event) {
|
||||||
function handleReverse() {
|
function handleReverse() {
|
||||||
isReversed = !isReversed;
|
isReversed = !isReversed;
|
||||||
|
|
||||||
// Update button appearance
|
// Update button appearance and accessibility state
|
||||||
reverseButton.classList.toggle('reversed', isReversed);
|
reverseButton.classList.toggle('reversed', isReversed);
|
||||||
|
reverseButton.setAttribute('aria-pressed', isReversed.toString());
|
||||||
|
|
||||||
// Reverse the current filtered data
|
// Reverse the current filtered data
|
||||||
filteredData.reverse();
|
filteredData.reverse();
|
||||||
|
|
@ -481,6 +467,7 @@ function resetReverse() {
|
||||||
if (isReversed) {
|
if (isReversed) {
|
||||||
isReversed = false;
|
isReversed = false;
|
||||||
reverseButton.classList.remove('reversed');
|
reverseButton.classList.remove('reversed');
|
||||||
|
reverseButton.setAttribute('aria-pressed', 'false');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
139
styles.css
139
styles.css
|
|
@ -5,6 +5,40 @@
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Screen reader only utility class */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skip link for keyboard navigation */
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
top: -40px;
|
||||||
|
left: 0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: var(--text-light);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
z-index: 1000;
|
||||||
|
border-radius: 0 0 4px 0;
|
||||||
|
transition: top 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
top: 0;
|
||||||
|
outline: 2px solid var(--text-light);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Theme Variables */
|
/* Theme Variables */
|
||||||
:root {
|
:root {
|
||||||
/* Default theme - Gruvbox */
|
/* Default theme - Gruvbox */
|
||||||
|
|
@ -228,11 +262,17 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-select-header:focus {
|
.theme-select-header:focus {
|
||||||
outline: none;
|
outline: 2px solid var(--text-light);
|
||||||
|
outline-offset: 2px;
|
||||||
border-color: rgba(255, 255, 255, 0.6);
|
border-color: rgba(255, 255, 255, 0.6);
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-select-header:focus-visible {
|
||||||
|
outline: 2px solid var(--text-light);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.theme-select-header option {
|
.theme-select-header option {
|
||||||
background: var(--card-background);
|
background: var(--card-background);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
|
@ -269,12 +309,18 @@ body {
|
||||||
border: 2px solid #e1e5e9;
|
border: 2px solid #e1e5e9;
|
||||||
border-radius: 8px 0 0 8px;
|
border-radius: 8px 0 0 8px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.3s ease;
|
transition: border-color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input:focus {
|
.search-input:focus {
|
||||||
border-color: #667eea;
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: -2px;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-button {
|
.search-button {
|
||||||
|
|
@ -299,6 +345,11 @@ body {
|
||||||
background: var(--secondary-color);
|
background: var(--secondary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-button:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|
@ -316,7 +367,6 @@ body {
|
||||||
border: 2px solid #e1e5e9;
|
border: 2px solid #e1e5e9;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.3s ease;
|
transition: border-color 0.3s ease;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--card-background);
|
background: var(--card-background);
|
||||||
|
|
@ -325,9 +375,16 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-select:focus {
|
.filter-select:focus {
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-select:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-select option {
|
.filter-select option {
|
||||||
background: var(--card-background);
|
background: var(--card-background);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
|
@ -345,7 +402,11 @@ body {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
outline: none;
|
}
|
||||||
|
|
||||||
|
.reverse-button:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reverse-button:hover {
|
.reverse-button:hover {
|
||||||
|
|
@ -401,14 +462,20 @@ body {
|
||||||
border: 2px solid #e1e5e9;
|
border: 2px solid #e1e5e9;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.3s ease;
|
transition: border-color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rank-input:focus {
|
.rank-input:focus {
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: -2px;
|
||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rank-input:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
.theme-selector {
|
.theme-selector {
|
||||||
margin-left: 1rem;
|
margin-left: 1rem;
|
||||||
}
|
}
|
||||||
|
|
@ -418,7 +485,6 @@ body {
|
||||||
border: 2px solid #e1e5e9;
|
border: 2px solid #e1e5e9;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.3s ease;
|
transition: border-color 0.3s ease;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--card-background);
|
background: var(--card-background);
|
||||||
|
|
@ -427,9 +493,16 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-select:focus {
|
.theme-select:focus {
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-select:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.theme-select option {
|
.theme-select option {
|
||||||
background: var(--card-background);
|
background: var(--card-background);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
|
@ -444,7 +517,11 @@ body {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
outline: none;
|
}
|
||||||
|
|
||||||
|
.jump-button:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.jump-button:hover {
|
.jump-button:hover {
|
||||||
|
|
@ -473,6 +550,9 @@ body {
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
font-family: inherit;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-item:hover {
|
.stat-item:hover {
|
||||||
|
|
@ -481,6 +561,11 @@ body {
|
||||||
background: var(--hover-background);
|
background: var(--hover-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-item:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.stat-number {
|
.stat-number {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
|
|
@ -622,12 +707,16 @@ body {
|
||||||
transform: scale(0.95);
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.album-share:focus-visible {
|
||||||
|
outline: 2px solid var(--text-light);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.album-share.copied {
|
.album-share.copied {
|
||||||
background: var(--secondary-color);
|
background: var(--secondary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.album-card:hover {
|
.album-card:hover {
|
||||||
transform: translateY(-4px);
|
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
|
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -782,6 +871,11 @@ body {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wikipedia-link:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.spotify-link {
|
.spotify-link {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
color: #1db954; /* Spotify green */
|
color: #1db954; /* Spotify green */
|
||||||
|
|
@ -804,6 +898,11 @@ body {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.spotify-link:focus-visible {
|
||||||
|
outline: 2px solid #1db954;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Dark theme adjustments for Spotify link */
|
/* Dark theme adjustments for Spotify link */
|
||||||
[data-theme="dark"] .spotify-link,
|
[data-theme="dark"] .spotify-link,
|
||||||
[data-theme="gruvbox"] .spotify-link,
|
[data-theme="gruvbox"] .spotify-link,
|
||||||
|
|
@ -846,6 +945,11 @@ body {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.next-album-link:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.next-album-link svg {
|
.next-album-link svg {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
@ -1061,3 +1165,20 @@ body {
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reduced motion preference */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
animation: none;
|
||||||
|
border: 3px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue