Harden accessibility and add resilience improvements

- Add skip-to-content link for keyboard navigation (WCAG 2.4.1)
- Add ARIA labels to download buttons and periodic table elements
- Convert periodic table from divs to semantic buttons with keyboard support
- Add focus-visible styles to all interactive elements
- Implement WAI-ARIA tabs pattern for pattern categories with arrow key nav
- Add global prefers-reduced-motion support in CSS and JavaScript
- Fix touch gesture trapping in split-compare (allows vertical scroll)
- Add error states with retry button for API failures
- Add underlines to links for non-color identification (WCAG 1.4.1)
- Respect reduced motion preference in Lenis smooth scroll and hero canvas

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2025-12-16 14:02:30 -08:00
co-authored by Claude Opus 4.5
parent 5d65154dee
commit ecc4857204
7 changed files with 366 additions and 44 deletions
+130 -18
View File
@@ -25,6 +25,14 @@ async function loadContent() {
fetch("/api/patterns"),
]);
// Check for HTTP errors
if (!commandsRes.ok) {
throw new Error(`Commands API failed: ${commandsRes.status}`);
}
if (!patternsRes.ok) {
throw new Error(`Patterns API failed: ${patternsRes.status}`);
}
allCommands = await commandsRes.json();
const patternsData = await patternsRes.json();
@@ -35,6 +43,39 @@ async function loadContent() {
renderPatternsWithTabs(patternsData.patterns, patternsData.antipatterns);
} catch (error) {
console.error("Failed to load content:", error);
showLoadError(error);
}
}
function showLoadError(error) {
// Show error in commands section
const commandsGallery = document.querySelector('.commands-gallery');
if (commandsGallery) {
commandsGallery.innerHTML = `
<div class="load-error" role="alert">
<div class="load-error-icon" aria-hidden="true">⚠</div>
<h3 class="load-error-title">Failed to load commands</h3>
<p class="load-error-text">There was a problem loading the content. Please check your connection and try again.</p>
<button class="btn btn-secondary load-error-retry" onclick="location.reload()">
Retry
</button>
</div>
`;
}
// Show error in patterns section
const patternsContainer = document.getElementById("patterns-categories");
if (patternsContainer) {
patternsContainer.innerHTML = `
<div class="load-error" role="alert">
<div class="load-error-icon" aria-hidden="true">⚠</div>
<h3 class="load-error-title">Failed to load patterns</h3>
<p class="load-error-text">There was a problem loading the content. Please check your connection and try again.</p>
<button class="btn btn-secondary load-error-retry" onclick="location.reload()">
Retry
</button>
</div>
`;
}
}
@@ -48,27 +89,46 @@ function renderPatternsWithTabs(patterns, antipatterns) {
antipatternMap[cat.name] = cat.items;
});
// Build tabs
// Generate unique IDs for tabs
const tabId = (name) => `pattern-tab-${name.toLowerCase().replace(/\s+/g, '-')}`;
const panelId = (name) => `pattern-panel-${name.toLowerCase().replace(/\s+/g, '-')}`;
// Build tabs with WAI-ARIA attributes
const tabsHTML = patterns
.map((category, i) => `<button class="pattern-tab${i === 0 ? ' active' : ''}" data-tab="${category.name}">${category.name}</button>`)
.map((category, i) => `<button
class="pattern-tab${i === 0 ? ' active' : ''}"
data-tab="${category.name}"
role="tab"
id="${tabId(category.name)}"
aria-selected="${i === 0 ? 'true' : 'false'}"
aria-controls="${panelId(category.name)}"
tabindex="${i === 0 ? '0' : '-1'}"
>${category.name}</button>`)
.join("");
// Build panels
// Build panels with WAI-ARIA attributes
const panelsHTML = patterns
.map((category, i) => {
const antiItems = antipatternMap[category.name] || [];
return `
<div class="pattern-panel${i === 0 ? ' active' : ''}" data-panel="${category.name}">
<div
class="pattern-panel${i === 0 ? ' active' : ''}"
data-panel="${category.name}"
role="tabpanel"
id="${panelId(category.name)}"
aria-labelledby="${tabId(category.name)}"
${i !== 0 ? 'hidden' : ''}
>
<div class="pattern-columns">
<div class="pattern-column pattern-column--anti">
<span class="pattern-column-label">Don't</span>
<ul class="pattern-list">
<span class="pattern-column-label" id="dont-label-${i}">Don't</span>
<ul class="pattern-list" aria-labelledby="dont-label-${i}">
${antiItems.map((item) => `<li class="pattern-item pattern-item--anti">${item}</li>`).join("")}
</ul>
</div>
<div class="pattern-column pattern-column--do">
<span class="pattern-column-label">Do</span>
<ul class="pattern-list">
<span class="pattern-column-label" id="do-label-${i}">Do</span>
<ul class="pattern-list" aria-labelledby="do-label-${i}">
${category.items.map((item) => `<li class="pattern-item pattern-item--do">${item}</li>`).join("")}
</ul>
</div>
@@ -79,22 +139,74 @@ function renderPatternsWithTabs(patterns, antipatterns) {
.join("");
container.innerHTML = `
<div class="pattern-tabs">${tabsHTML}</div>
<div class="pattern-tabs" role="tablist" aria-label="Pattern categories">${tabsHTML}</div>
<div class="pattern-panels">${panelsHTML}</div>
`;
const tabs = container.querySelectorAll('.pattern-tab');
const panels = container.querySelectorAll('.pattern-panel');
// Function to switch tabs
const switchTab = (newTab) => {
const tabName = newTab.dataset.tab;
// Update ARIA attributes on all tabs
tabs.forEach(t => {
t.classList.remove('active');
t.setAttribute('aria-selected', 'false');
t.setAttribute('tabindex', '-1');
});
// Activate the new tab
newTab.classList.add('active');
newTab.setAttribute('aria-selected', 'true');
newTab.setAttribute('tabindex', '0');
newTab.focus();
// Update panels
panels.forEach(p => {
p.classList.remove('active');
p.setAttribute('hidden', '');
});
const activePanel = container.querySelector(`[data-panel="${tabName}"]`);
activePanel.classList.add('active');
activePanel.removeAttribute('hidden');
};
// Tab click handling
container.querySelectorAll('.pattern-tab').forEach(tab => {
tab.addEventListener('click', () => {
const tabName = tab.dataset.tab;
tabs.forEach(tab => {
tab.addEventListener('click', () => switchTab(tab));
});
// Update active tab
container.querySelectorAll('.pattern-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
// Keyboard navigation (Arrow keys, Home, End)
tabs.forEach((tab, index) => {
tab.addEventListener('keydown', (e) => {
let targetIndex = index;
// Update active panel
container.querySelectorAll('.pattern-panel').forEach(p => p.classList.remove('active'));
container.querySelector(`[data-panel="${tabName}"]`).classList.add('active');
switch (e.key) {
case 'ArrowLeft':
case 'ArrowUp':
e.preventDefault();
targetIndex = index === 0 ? tabs.length - 1 : index - 1;
break;
case 'ArrowRight':
case 'ArrowDown':
e.preventDefault();
targetIndex = index === tabs.length - 1 ? 0 : index + 1;
break;
case 'Home':
e.preventDefault();
targetIndex = 0;
break;
case 'End':
e.preventDefault();
targetIndex = tabs.length - 1;
break;
default:
return;
}
switchTab(tabs[targetIndex]);
});
});
}
+142 -2
View File
@@ -82,6 +82,31 @@
}
}
/* ============================================
SKIP LINK (Accessibility)
============================================ */
.skip-link {
position: absolute;
top: -100%;
left: 50%;
transform: translateX(-50%);
z-index: 10000;
padding: var(--spacing-sm) var(--spacing-lg);
background: var(--color-ink);
color: var(--color-paper);
font-weight: 600;
text-decoration: none;
border-radius: 0 0 8px 8px;
transition: top 0.2s ease;
}
.skip-link:focus {
top: 0;
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
/* ============================================
BASE STYLES
============================================ */
@@ -132,12 +157,22 @@ h1, h2, h3, h4, h5, h6 {
a {
color: var(--color-accent);
text-decoration: none;
transition: color var(--duration-fast) var(--ease-out);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
transition: color var(--duration-fast) var(--ease-out), text-decoration-color var(--duration-fast) var(--ease-out);
}
a:hover {
color: var(--color-accent-hover);
text-decoration-thickness: 2px;
}
/* Remove underline for buttons styled as links */
.btn,
.footer-logo,
[class*="nav-item"] {
text-decoration: none;
}
strong {
@@ -1210,6 +1245,21 @@ code {
color: var(--color-paper);
}
/* Focus styles for all buttons */
.btn:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.btn-primary:focus-visible {
outline-color: var(--color-paper);
box-shadow: 0 0 0 4px var(--color-accent);
}
.btn-secondary:focus-visible {
outline-color: var(--color-accent);
}
/* ============================================
ANIMATIONS
============================================ */
@@ -1255,6 +1305,90 @@ code {
[data-reveal]:nth-child(3) { transition-delay: 0.2s; }
[data-reveal]:nth-child(4) { transition-delay: 0.3s; }
/* ============================================
REDUCED MOTION (Accessibility)
============================================ */
@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;
}
/* Allow smooth scrolling to remain for anchor links only if user explicitly triggers */
html {
scroll-behavior: auto;
}
/* Disable hero canvas animation */
.hero-canvas {
display: none;
}
/* Disable scroll indicator animation */
.hero-scroll-indicator {
animation: none;
opacity: 1;
}
/* Disable reveal animations - show content immediately */
[data-reveal] {
opacity: 1;
transform: none;
}
/* Disable gallery frame transitions */
.gallery-frame {
opacity: 1;
transform: none;
}
}
/* ============================================
ERROR STATES
============================================ */
.load-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--spacing-2xl) var(--spacing-lg);
gap: var(--spacing-md);
background: var(--color-cream);
border: 1px solid var(--color-mist);
border-radius: 8px;
}
.load-error-icon {
font-size: 2.5rem;
color: var(--color-accent);
}
.load-error-title {
font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 400;
color: var(--color-ink);
margin: 0;
}
.load-error-text {
font-size: 1rem;
color: var(--color-ash);
max-width: 40ch;
line-height: 1.5;
}
.load-error-retry {
margin-top: var(--spacing-sm);
}
/* ============================================
IMPORTS
============================================ */
@@ -1385,6 +1519,12 @@ code {
background: var(--color-accent);
}
.pattern-tab:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
border-radius: 4px;
}
/* Panels */
.pattern-panel {
display: none;
+13 -10
View File
@@ -22,9 +22,12 @@
</script>
</head>
<body class="min-h-screen overflow-x-hidden leading-relaxed">
<!-- Skip to main content link for keyboard users -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<!-- Grain Overlay -->
<div class="grain-overlay" aria-hidden="true"></div>
<!-- 1. HERO - Liquid Typography -->
<header class="hero-section" id="hero">
<canvas class="hero-canvas" id="hero-canvas"></canvas>
@@ -49,7 +52,7 @@
</div>
</header>
<main class="site-content">
<main class="site-content" id="main-content">
<!-- 2. THE PROBLEM - AI Design Bias -->
<section class="problem-section" id="problem">
<div class="section-header" data-reveal>
@@ -225,9 +228,9 @@
<span class="download-card-badge">Rules + Commands</span>
<p class="download-card-desc">Commands and rules for Cursor IDE. Works via appending, no frontmatter required.</p>
</div>
<button class="btn btn-primary" data-bundle="cursor">
<button class="btn btn-primary" data-bundle="cursor" aria-label="Download Cursor bundle">
<span>Download</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/>
</svg>
</button>
@@ -244,9 +247,9 @@
<span class="download-card-badge">Full Featured</span>
<p class="download-card-desc">Complete YAML frontmatter with argument support. Follows Anthropic Skills specification.</p>
</div>
<button class="btn btn-primary" data-bundle="claude-code">
<button class="btn btn-primary" data-bundle="claude-code" aria-label="Download Claude Code bundle">
<span>Download</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/>
</svg>
</button>
@@ -263,9 +266,9 @@
<span class="download-card-badge">TOML + Modular</span>
<p class="download-card-desc">TOML-formatted commands with modular skill imports via GEMINI.md.</p>
</div>
<button class="btn btn-primary" data-bundle="gemini">
<button class="btn btn-primary" data-bundle="gemini" aria-label="Download Gemini CLI bundle">
<span>Download</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/>
</svg>
</button>
@@ -282,9 +285,9 @@
<span class="download-card-badge">Prompts + Routing</span>
<p class="download-card-desc">Custom prompt format with skill routing through AGENTS.md architecture.</p>
</div>
<button class="btn btn-primary" data-bundle="codex">
<button class="btn btn-primary" data-bundle="codex" aria-label="Download Codex CLI bundle">
<span>Download</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/>
</svg>
</button>
+21 -8
View File
@@ -261,7 +261,9 @@ export class PeriodicTable {
createElement(cmd, category) {
const colors = categoryColors[category];
const el = document.createElement('div');
const el = document.createElement('button');
el.type = 'button';
el.setAttribute('aria-label', `/${cmd} command - ${categoryLabels[category]}`);
el.style.cssText = `
width: 56px;
height: 64px;
@@ -275,6 +277,8 @@ export class PeriodicTable {
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
position: relative;
font-family: inherit;
padding: 0;
`;
// Atomic number
@@ -315,8 +319,8 @@ export class PeriodicTable {
name.textContent = `/${cmd}`;
el.appendChild(name);
// Hover effects
el.addEventListener('mouseenter', () => {
// Shared handler for activation (hover or focus)
const activate = () => {
// Visual feedback
el.style.transform = 'translateY(-2px)';
el.style.boxShadow = `0 4px 12px ${colors.border}40`;
@@ -325,19 +329,28 @@ export class PeriodicTable {
this.showCommandInfo(cmd);
// Track active element
if (this.activeElement) {
if (this.activeElement && this.activeElement !== el) {
this.activeElement.style.transform = 'translateY(0)';
this.activeElement.style.boxShadow = 'none';
}
this.activeElement = el;
});
};
el.addEventListener('mouseleave', () => {
// Shared handler for deactivation
const deactivate = () => {
el.style.transform = 'translateY(0)';
el.style.boxShadow = 'none';
});
};
// Click to scroll to command
// Mouse events
el.addEventListener('mouseenter', activate);
el.addEventListener('mouseleave', deactivate);
// Keyboard focus events
el.addEventListener('focus', activate);
el.addEventListener('blur', deactivate);
// Click/Enter to scroll to command
el.addEventListener('click', () => {
const target = document.getElementById(`cmd-${cmd}`);
if (target) {
+6
View File
@@ -2,6 +2,12 @@ export function initHeroEffect() {
const canvas = document.getElementById("hero-canvas");
if (!canvas) return;
// Respect user's motion preferences
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
canvas.style.display = 'none';
return;
}
const ctx = canvas.getContext("2d");
let width, height;
let points = [];
+35 -5
View File
@@ -94,22 +94,52 @@ export function initSplitCompare(container, options = {}) {
}
}
function handleTouchStart() {
let touchStartX = 0;
let touchStartY = 0;
let isDragging = false;
const DRAG_THRESHOLD = 10; // Minimum horizontal movement to start dragging
function handleTouchStart(e) {
const touch = e.touches[0];
touchStartX = touch.clientX;
touchStartY = touch.clientY;
isDragging = false;
isHovering = true;
}
function handleTouchEnd() {
isHovering = false;
isDragging = false;
targetX = defaultPosition;
startAnimation();
}
function handleTouchMove(e) {
e.preventDefault();
const touch = e.touches[0];
const rect = splitContainer.getBoundingClientRect();
targetX = ((touch.clientX - rect.left) / rect.width) * 100;
startAnimation();
const deltaX = Math.abs(touch.clientX - touchStartX);
const deltaY = Math.abs(touch.clientY - touchStartY);
// Only start dragging if horizontal movement is greater than vertical
// This allows vertical scrolling to pass through
if (!isDragging) {
if (deltaX > DRAG_THRESHOLD && deltaX > deltaY) {
isDragging = true;
} else if (deltaY > DRAG_THRESHOLD) {
// User is scrolling vertically, don't interfere
return;
} else {
// Not enough movement yet
return;
}
}
// Only prevent default when actively dragging horizontally
if (isDragging) {
e.preventDefault();
const rect = splitContainer.getBoundingClientRect();
targetX = ((touch.clientX - rect.left) / rect.width) * 100;
startAnimation();
}
}
// Attach listeners
+19 -1
View File
@@ -1,6 +1,24 @@
import Lenis from "lenis";
// Check if user prefers reduced motion
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
export function initSmoothScroll() {
// Skip smooth scroll entirely if user prefers reduced motion
if (prefersReducedMotion) {
// Still handle anchor links but with instant scroll
document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
anchor.addEventListener("click", (e) => {
e.preventDefault();
const target = document.querySelector(anchor.getAttribute("href"));
if (target) {
target.scrollIntoView({ behavior: 'auto', block: 'start' });
}
});
});
return null;
}
const lenis = new Lenis({
duration: 1.2,
easing: (t) => Math.min(1, 1.001 - 2 ** (-10 * t)),
@@ -28,7 +46,7 @@ export function initSmoothScroll() {
}
});
});
return lenis;
}