diff --git a/public/app.js b/public/app.js index 8bba49dbe..8f77ed6ab 100644 --- a/public/app.js +++ b/public/app.js @@ -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 = ` + + `; + } + + // Show error in patterns section + const patternsContainer = document.getElementById("patterns-categories"); + if (patternsContainer) { + patternsContainer.innerHTML = ` + + `; } } @@ -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) => ``) + .map((category, i) => ``) .join(""); - // Build panels + // Build panels with WAI-ARIA attributes const panelsHTML = patterns .map((category, i) => { const antiItems = antipatternMap[category.name] || []; return ` -
+
- Don't -
    + Don't +
      ${antiItems.map((item) => `
    • ${item}
    • `).join("")}
- Do -
    + Do +
      ${category.items.map((item) => `
    • ${item}
    • `).join("")}
@@ -79,22 +139,74 @@ function renderPatternsWithTabs(patterns, antipatterns) { .join(""); container.innerHTML = ` -
${tabsHTML}
+
${tabsHTML}
${panelsHTML}
`; + 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]); }); }); } diff --git a/public/css/main.css b/public/css/main.css index ca36ec8af..b5c70af2b 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -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; diff --git a/public/index.html b/public/index.html index 7c3b57a80..4eb688bf5 100644 --- a/public/index.html +++ b/public/index.html @@ -22,9 +22,12 @@ + + + - +
@@ -49,7 +52,7 @@
-
+
@@ -225,9 +228,9 @@ Rules + Commands

Commands and rules for Cursor IDE. Works via appending, no frontmatter required.

- @@ -244,9 +247,9 @@ Full Featured

Complete YAML frontmatter with argument support. Follows Anthropic Skills specification.

- @@ -263,9 +266,9 @@ TOML + Modular

TOML-formatted commands with modular skill imports via GEMINI.md.

- @@ -282,9 +285,9 @@ Prompts + Routing

Custom prompt format with skill routing through AGENTS.md architecture.

- diff --git a/public/js/components/framework-viz.js b/public/js/components/framework-viz.js index e0e78f0bb..ee5bb3899 100644 --- a/public/js/components/framework-viz.js +++ b/public/js/components/framework-viz.js @@ -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) { diff --git a/public/js/effects/liquid-canvas.js b/public/js/effects/liquid-canvas.js index 5feda32e0..0f77b692e 100644 --- a/public/js/effects/liquid-canvas.js +++ b/public/js/effects/liquid-canvas.js @@ -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 = []; diff --git a/public/js/effects/split-compare.js b/public/js/effects/split-compare.js index cdf77b115..66ffd89b1 100644 --- a/public/js/effects/split-compare.js +++ b/public/js/effects/split-compare.js @@ -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 diff --git a/public/js/utils/scroll.js b/public/js/utils/scroll.js index cc4f7efe7..b99538c55 100644 --- a/public/js/utils/scroll.js +++ b/public/js/utils/scroll.js @@ -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; }