Files
cv-site/doc/21-ACCESSIBILITY.md
juanatsap 40733034ca feat: comprehensive WCAG 2.1 AA accessibility audit
- Add aria-labels to menu action buttons (PDF, Print, Contact)
- Add aria-labelledby to toggle checkboxes (desktop + mobile)
- Add -webkit-user-select prefix for Safari compatibility
- Add DynamicCacheControl middleware for HTML pages
- Add accessibility test suite (60-accessibility.test.mjs)
- Add comprehensive accessibility documentation (21-ACCESSIBILITY.md)
- Update Modern Web Techniques doc to mark audit complete
2025-12-02 10:46:53 +00:00

12 KiB

Accessibility Guide

WCAG 2.1 AA Compliance Documentation Last Updated: December 2025

Overview

This document describes the accessibility features implemented in the CV website to ensure WCAG 2.1 AA compliance and provide an inclusive user experience.

Table of Contents

  1. Implemented Features
  2. Button Accessibility
  3. Form Elements
  4. Keyboard Navigation
  5. Screen Reader Support
  6. CSS Compatibility
  7. HTTP Headers
  8. Testing
  9. Checklist

Implemented Features

Quick Summary

Feature Status Notes
Button aria-labels Complete All buttons have discernible text
Form labels Complete All inputs have aria-labelledby
Keyboard navigation Complete Tab, Enter, Escape support
Modal accessibility Complete Native <dialog> with close buttons
Color themes Complete Light/Dark/Auto modes
Screen reader Complete Live regions for announcements
CSS prefixes Complete Safari/WebKit compatibility
Security headers Complete X-Content-Type-Options, CSP
Cache headers Complete Static and dynamic routes

Button Accessibility

All interactive buttons include proper accessibility attributes:

Fixed Action Buttons

Located in templates/partials/widgets/:

<!-- Download PDF Button -->
<button id="download-button"
        aria-label="{{.UI.Widgets.Download.AriaLabel}}"
        data-tooltip="{{.UI.Widgets.Download.Tooltip}}">
    <iconify-icon icon="catppuccin:pdf"></iconify-icon>
</button>

<!-- Print-Friendly Button -->
<button id="print-friendly-button"
        aria-label="{{.UI.Widgets.Print.AriaLabel}}"
        data-tooltip="{{.UI.Widgets.Print.Tooltip}}">
    <iconify-icon icon="mdi:leaf"></iconify-icon>
</button>

<!-- Shortcuts Button -->
<button id="shortcuts-button"
        aria-label="{{.UI.Widgets.Shortcuts.AriaLabel}}"
        data-tooltip="{{.UI.Widgets.Shortcuts.Tooltip}}">
    <iconify-icon icon="mdi:keyboard-outline"></iconify-icon>
</button>

Mobile Menu Buttons

Located in templates/partials/navigation/hamburger-menu.html:

<!-- All menu action buttons have aria-labels -->
<button class="menu-action-btn menu-pdf-btn"
        aria-label="{{.UI.Widgets.ActionButtons.DownloadPdf}}">
    <iconify-icon icon="catppuccin:pdf"></iconify-icon>
    <span>{{.UI.Widgets.ActionButtons.DownloadPdf}}</span>
</button>

Best Practices

  1. Icon-only buttons: Always include aria-label
  2. Buttons with text: Visible text serves as the accessible name
  3. Tooltips: Use data-tooltip for visual hint, aria-label for screen readers

Form Elements

All form inputs have proper label associations:

Toggle Checkboxes

Desktop toggles in templates/partials/navigation/view-controls.html:

<div class="selector-group" id="desktop-length-toggle">
    <label class="selector-label" id="length-toggle-label">
        {{.UI.ViewControls.Length}}:
    </label>
    <label class="icon-toggle">
        <input type="checkbox"
               id="lengthToggle"
               aria-labelledby="length-toggle-label"
               aria-describedby="length-toggle-desc">
        <span class="icon-toggle-slider">...</span>
        <span id="length-toggle-desc" class="sr-only">
            {{.UI.ViewControls.LengthDescription}}
        </span>
    </label>
</div>

Mobile toggles in templates/partials/navigation/hamburger-menu.html:

<div class="menu-control-item" id="mobile-length-toggle">
    <label class="menu-control-label" id="menu-length-toggle-label">
        <iconify-icon icon="mdi:file-document-outline"></iconify-icon>
        <span>{{.UI.ViewControls.Length}}</span>
    </label>
    <label class="icon-toggle">
        <input type="checkbox"
               id="lengthToggleMenu"
               aria-labelledby="menu-length-toggle-label">
        <span class="icon-toggle-slider">...</span>
    </label>
</div>

Contact Form

Located in templates/partials/modals/contact-modal.html:

<div class="form-group">
    <label for="contact-email" class="form-label">
        {{.UI.ContactModal.Form.Email}}
        <span class="required-indicator">*</span>
    </label>
    <input type="email"
           id="contact-email"
           name="email"
           required
           aria-required="true"
           placeholder="{{.UI.ContactModal.Form.EmailPlaceholder}}">
</div>

Labeling Strategies

Strategy When to Use
<label for="id"> Standard form inputs
aria-labelledby Complex widgets, toggles
aria-describedby Additional context/descriptions
aria-label When no visible label exists

Keyboard Navigation

Supported Shortcuts

Key Action
Tab Move focus to next element
Shift+Tab Move focus to previous element
Enter / Space Activate focused button/link
Escape Close modals
? Open shortcuts modal
Ctrl/Cmd + K Open command palette
Ctrl/Cmd + P Print friendly version
Ctrl/Cmd + +/-/0 Zoom controls

Focus Management

  • All interactive elements are focusable
  • Focus is trapped inside open modals
  • Focus returns to trigger element when modal closes
  • Skip links available for screen reader users

Implementation

<!-- Modal with keyboard support -->
<dialog id="shortcuts-modal" class="info-modal"
        _="on click call closeOnBackdrop(me, event)">
    <!-- Press Escape to close (native dialog behavior) -->
    <button class="info-modal-close"
            commandfor="shortcuts-modal"
            command="close"
            aria-label="{{.UI.ShortcutsModal.Close}}">
        <iconify-icon icon="mdi:close"></iconify-icon>
    </button>
</dialog>

Screen Reader Support

Live Regions

Announcements for dynamic content changes:

<!-- Loading indicator -->
<span id="loading"
      role="status"
      aria-live="polite"
      aria-label="Loading">
    <span class="loader"></span>
</span>

<!-- PDF selection announcement -->
<div id="pdf-selection-announcement"
     class="sr-only"
     aria-live="polite"
     aria-atomic="true"></div>

<!-- Contact form response -->
<div id="contact-response"
     class="contact-response"
     role="status"
     aria-live="polite"></div>

Screen Reader Only Text

.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;
}

ARIA Landmarks

<!-- Navigation -->
<nav role="navigation" aria-label="CV sections">...</nav>
<div class="action-bar" role="navigation" aria-label="Language and export controls">...</div>

<!-- Zoom control -->
<div id="zoom-control" role="group" aria-label="{{.UI.Widgets.ZoomControl.GroupLabel}}">
    <input type="range"
           aria-label="{{.UI.Widgets.ZoomControl.SliderLabel}}"
           aria-valuemin="25"
           aria-valuemax="300"
           aria-valuenow="100"
           aria-valuetext="100%">
</div>

CSS Compatibility

Browser Prefixes

All CSS properties with limited browser support include vendor prefixes:

/* User selection prevention */
.toggle-switch {
    -webkit-user-select: none; /* Safari */
    user-select: none;
}

/* Backdrop blur effect */
.zoom-control {
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px); /* Safari */
}

Files Updated

File Property Fixed
_toggles.css -webkit-user-select
_zoom-control.css -webkit-user-select
_sidebar.css -webkit-user-select
_cv-section.css -webkit-user-select
_breakpoints.css -webkit-user-select
_toasts.css -webkit-backdrop-filter (already present)
_modals.css -webkit-backdrop-filter (already present)

Feature Detection

For backdrop-filter, use @supports:

@supports (backdrop-filter: blur(20px)) or (-webkit-backdrop-filter: blur(20px)) {
    .blur-bar {
        backdrop-filter: blur(20px) saturate(180%);
        -webkit-backdrop-filter: blur(20px) saturate(180%);
    }
}

HTTP Headers

Security Headers

Implemented in internal/middleware/security.go:

func SecurityHeaders(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Prevent MIME type sniffing
        w.Header().Set("X-Content-Type-Options", "nosniff")

        // Prevent clickjacking
        w.Header().Set("X-Frame-Options", "SAMEORIGIN")

        // XSS Protection
        w.Header().Set("X-XSS-Protection", "1; mode=block")

        // Referrer policy
        w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")

        // Content Security Policy
        w.Header().Set("Content-Security-Policy", "...")

        // HSTS (production only)
        if os.Getenv("GO_ENV") == "production" {
            w.Header().Set("Strict-Transport-Security",
                "max-age=31536000; includeSubDomains; preload")
        }

        next.ServeHTTP(w, r)
    })
}

Cache Control

Static Files (CSS, JS, images):

// 1 hour dev, 1 day production
w.Header().Set("Cache-Control", "public, max-age=86400")

Dynamic Routes (HTML pages):

// Production: 5 minutes with must-revalidate
w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate")

// Development: no cache
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")

Testing

Running Accessibility Tests

# Run accessibility test suite
bun run tests/mjs/60-accessibility.test.mjs

# Or with the test runner
cd tests && bun run run-all.mjs

Test Coverage

The 60-accessibility.test.mjs file tests:

  1. HTTP Security Headers - X-Content-Type-Options, X-Frame-Options, CSP
  2. Cache-Control Headers - Presence and correct values
  3. Buttons with Discernible Text - All buttons have aria-label or visible text
  4. Form Elements with Labels - All inputs have associated labels
  5. Toggle Checkboxes - aria-labelledby with valid linked elements
  6. ARIA Landmarks - Navigation, main, dialog elements
  7. Keyboard Navigation - Focusable interactive elements
  8. Modal Accessibility - Close buttons, aria attributes
  9. Color Theme Support - Theme switcher availability
  10. Screen Reader Announcements - Live regions for dynamic content

Manual Testing

  1. Keyboard-only navigation: Tab through all interactive elements
  2. Screen reader testing: Use VoiceOver (macOS) or NVDA (Windows)
  3. High contrast mode: Test visibility in Windows High Contrast
  4. Zoom testing: Test at 200% browser zoom

Accessibility Checklist

Before Each Release

  • Run 60-accessibility.test.mjs - all tests pass
  • Test keyboard navigation (Tab, Enter, Escape)
  • Verify all buttons have aria-labels
  • Check form inputs have labels
  • Test with screen reader
  • Verify modals trap focus
  • Check color contrast ratios
  • Test at 200% zoom

WCAG 2.1 AA Requirements

Criterion Status Implementation
1.1.1 Non-text Content Alt text, aria-labels
1.3.1 Info and Relationships Semantic HTML, ARIA
1.4.3 Contrast (Minimum) Theme system
2.1.1 Keyboard Full keyboard support
2.1.2 No Keyboard Trap Modal focus management
2.4.1 Bypass Blocks Skip links, landmarks
2.4.4 Link Purpose Descriptive link text
2.4.6 Headings and Labels Semantic structure
3.2.1 On Focus No unexpected changes
4.1.2 Name, Role, Value ARIA attributes

Resources


Changelog

December 2025

  • Added aria-labels to menu action buttons (PDF, Print, Contact)
  • Added aria-labelledby to all toggle checkboxes (desktop and mobile)
  • Added -webkit-user-select prefix for Safari compatibility
  • Added DynamicCacheControl middleware for HTML pages
  • Created comprehensive accessibility test suite
  • Created this documentation