Accessibility Improvements

Accessibility Improvements

Deep analysis and implementation guide for improving WCAG 2.1 AA compliance and overall accessibility.


Table of Contents

  1. Skip Navigation Link
  2. Focus Indicator Consistency
  3. Color Contrast Audit
  4. Keyboard Navigation Enhancements
  5. ARIA Live Regions
  6. Image Alt Text Audit
  7. Language Toggle Accessibility
  8. Color Picker Accessibility
  9. Lightbox Accessibility

Current State

File: _sass/_utilities.scss (lines 99-113)

The .skip-link class is defined in CSS but never instantiated in any layout HTML:

.skip-link {
  position: fixed;
  z-index: 20;
  margin: 0;
  font-family: $sans-serif;
  white-space: nowrap;
}

The .screen-reader-shortcut:focus styles (lines 80-94) exist to make skip links visible on focus:

.screen-reader-text:focus,
.screen-reader-shortcut:focus {
  clip: auto !important;
  height: auto !important;
  width: auto !important;
  display: block;
  font-size: 1em;
  font-weight: bold;
  padding: 15px 23px 14px;
  background: #fff;
  z-index: 100000;
}

Implementation

Add a skip link as the first focusable element in _layouts/default.html:

<!-- _layouts/default.html - add as first child of <body> -->
<body>
  <a class="skip-link screen-reader-shortcut" href="#main">
    Skip to main content
  </a>

  <div class="gradient-mesh" aria-hidden="true">
    <!-- ... existing mesh divs ... -->
  </div>
  

<div class="masthead">
  <!-- Scroll progress bar -->
  <div class="scroll-progress" aria-hidden="true"></div>
  <div class="masthead__inner-wrap">
    <div class="masthead__menu">
      <nav id="site-nav" class="site-nav">
        <a class="site-title" href="https://efrenrodriguezrodriguez.com/">Efrén Rodríguez Rodríguez</a>
        <button class="nav-toggle" aria-label="Toggle navigation" aria-expanded="false">
          <span class="navicon"></span>
        </button>
        <ul class="nav-links">
          
            
            <li>
              <a href="https://efrenrodriguezrodriguez.com/publications/">
                
                  <span class="lang-en" lang="en">Research</span>
                  <span class="lang-es" lang="es">Investigación</span>
                
              </a>
            </li>
          
            
            <li>
              <a href="https://efrenrodriguezrodriguez.com/portfolio/">
                
                  <span class="lang-en" lang="en">Projects</span>
                  <span class="lang-es" lang="es">Proyectos</span>
                
              </a>
            </li>
          
            
            <li>
              <a href="https://efrenrodriguezrodriguez.com/talks/">
                
                  <span class="lang-en" lang="en">Talks</span>
                  <span class="lang-es" lang="es">Charlas</span>
                
              </a>
            </li>
          
            
            <li>
              <a href="https://efrenrodriguezrodriguez.com/year-archive/">
                
                  <span class="lang-en" lang="en">Blog</span>
                  <span class="lang-es" lang="es">Blog</span>
                
              </a>
            </li>
          
            
            <li>
              <a href="https://efrenrodriguezrodriguez.com/cv/">
                
                  <span class="lang-en" lang="en">CV</span>
                  <span class="lang-es" lang="es">CV</span>
                
              </a>
            </li>
          
            
            <li>
              <a href="https://efrenrodriguezrodriguez.com/contact/">
                
                  <span class="lang-en" lang="en">Contact</span>
                  <span class="lang-es" lang="es">Contacto</span>
                
              </a>
            </li>
          
            
            <li>
              <a href="https://efrenrodriguezrodriguez.com/personal/">
                
                  <span class="lang-en" lang="en">Personal</span>
                  <span class="lang-es" lang="es">Personal</span>
                
              </a>
            </li>
          
        </ul>
        
        <button class="search-toggle" aria-label="Search" title="Search">
          <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" 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"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
        </button>
        
        <button class="theme-toggle" aria-label="Toggle dark mode" title="Toggle dark mode">
          <svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
          <svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
        </button>
        <button class="lang-toggle" aria-label="Toggle language" title="Toggle language">
          <span class="lang-label lang-label--en">EN</span>
          <span class="lang-label lang-label--es">ES</span>
        </button>
        <button class="color-toggle" aria-label="Change accent color" aria-expanded="false" aria-controls="color-picker" title="Change accent color">
          <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="4" fill="currentColor"/></svg>
        </button>
        <div id="color-picker" class="color-picker" role="radiogroup" aria-label="Accent color scheme">
          <button class="color-swatch" role="radio" aria-checked="true" data-primary="#52adc8" data-accent="#8B5CF6" aria-label="Ocean Purple" tabindex="0" style="background:linear-gradient(135deg,#52adc8,#8B5CF6)"></button>
          <button class="color-swatch" role="radio" aria-checked="false" data-primary="#10b981" data-accent="#06b6d4" aria-label="Emerald Cyan" tabindex="-1" style="background:linear-gradient(135deg,#10b981,#06b6d4)"></button>
          <button class="color-swatch" role="radio" aria-checked="false" data-primary="#f59e0b" data-accent="#ef4444" aria-label="Amber Red" tabindex="-1" style="background:linear-gradient(135deg,#f59e0b,#ef4444)"></button>
          <button class="color-swatch" role="radio" aria-checked="false" data-primary="#ec4899" data-accent="#8b5cf6" aria-label="Pink Purple" tabindex="-1" style="background:linear-gradient(135deg,#ec4899,#8b5cf6)"></button>
          <button class="color-swatch" role="radio" aria-checked="false" data-primary="#6366f1" data-accent="#14b8a6" aria-label="Indigo Teal" tabindex="-1" style="background:linear-gradient(135deg,#6366f1,#14b8a6)"></button>
        </div>
      </nav>
    </div>
  </div>
</div>
  <!-- ... rest of layout ... -->

Update the CSS to make it properly functional:

// _sass/_utilities.scss - replace .skip-link definition

.skip-link {
  position: fixed;
  top: -100%;
  left: 1rem;
  z-index: 10000;
  padding: 0.75rem 1.5rem;
  background: var(--color-primary, #52adc8);
  color: #fff;
  font-family: $sans-serif;
  font-weight: 600;
  font-size: 0.875rem;
  text-decoration: none;
  border-radius: 0 0 0.5rem 0.5rem;
  white-space: nowrap;
  transition: top 0.2s ease;

  &:focus {
    top: 0;
    outline: 2px solid var(--color-accent, #8B5CF6);
    outline-offset: 2px;
  }
}

Ensure the #main target has proper scroll behavior:

// _sass/_base.scss - add
#main:target {
  scroll-margin-top: 4rem; // account for sticky masthead
}

2. Focus Indicator Consistency

Current State

Focus styles are defined in multiple places with inconsistent patterns:

Location Focus Style Issues
_utilities.scss:1235 outline: 2px solid var(--color-primary) with outline-offset: 3px Good - global focus-visible
_base.scss:131 %tab-focus mixin (dotted outline, warning color) Conflicts with global style
_forms.scss:224 outline: 0 with box-shadow ring Removes outline, shadow only
_masthead.scss:57 Hover state only, no focus-specific style Missing focus indicator
_navigation.scss:295 Hover underline animation, no focus Missing focus indicator

Implementation

Standardize on a single focus style

Remove conflicting focus definitions and ensure the global focus-visible is the baseline:

// _sass/_utilities.scss - keep and enhance the global rule

*:focus-visible {
  outline: 2px solid var(--color-primary, #52adc8);
  outline-offset: 3px;
  border-radius: 2px;
}

// High contrast for dark mode
[data-theme="dark"] *:focus-visible {
  outline-color: var(--color-primary, #67d4f1);
}

Fix navigation focus states

// _sass/_navigation.scss - add focus state alongside hover

.nav-links a {
  // ... existing styles ...

  &:hover,
  &:focus-visible {
    color: var(--color-primary, $info-color);
    &:before {
      transform: scaleX(1);
    }
  }
}

Fix masthead button focus states

// _sass/_masthead.scss - add focus states to toggle buttons

.theme-toggle,
.lang-toggle,
.color-toggle {
  // ... existing styles ...

  &:hover,
  &:focus-visible {
    color: var(--color-primary, $info-color);
    background: var(--color-background-secondary, $lighter-gray);
  }

  // Remove transform on focus (rotation can be disorienting)
  &:focus-visible {
    transform: none;
  }
}

Fix form focus states

// _sass/_forms.scss - ensure outline is visible, not just box-shadow

input:focus-visible,
textarea:focus-visible {
  border-color: var(--color-primary, $primary-color);
  outline: 2px solid var(--color-primary, $primary-color);
  outline-offset: -2px;
  box-shadow: 0 0 0 3px rgba(82, 173, 200, 0.25);
}

Remove conflicting %tab-focus mixin

In _sass/_mixins.scss, the %tab-focus mixin (lines 5-11) uses a warning color and dotted outline that conflicts with the cleaner global style. Either:

  • Remove the mixin entirely
  • Or update it to match the global pattern
// _sass/_mixins.scss - update to match global style

%tab-focus {
  outline: 2px solid var(--color-primary, #52adc8);
  outline-offset: 3px;
}

3. Color Contrast Audit

Current State

File: _sass/_variables.scss

The site offers 5 accent color schemes. Each must meet WCAG AA contrast ratios (4.5:1 for normal text, 3:1 for large text).

Light Mode Analysis (background: #fff)

Color Hex Contrast vs #fff AA Normal AA Large
Primary (Ocean) #52adc8 ~3.2:1 FAIL PASS
Accent (Purple) #8B5CF6 ~3.9:1 FAIL PASS
Text #1e293b ~15:1 PASS PASS
Text Light #64748b ~4.7:1 PASS PASS

Dark Mode Analysis (background: #0f172a)

Color Hex Contrast vs #0f172a AA Normal AA Large
Primary (Ocean) #67d4f1 ~8.5:1 PASS PASS
Accent (Purple) #a78bfa ~5.5:1 PASS PASS
Text #e2e8f0 ~14:1 PASS PASS
Text Light #94a3b8 ~6.5:1 PASS PASS

Issues

Light mode primary color #52adc8 fails AA for normal text. This color is used for:

  • Links
  • Navigation active state
  • Buttons
  • Accent highlights

Implementation

Darken the light-mode primary

// _sass/_variables.scss - update light mode primary

:root {
  --color-primary: #3a8fa8;  // Darkened from #52adc8 (now ~4.6:1 contrast)
  --color-accent: #7c3aed;   // Darkened from #8B5CF6 (now ~5.5:1 contrast)
}

Audit all 5 accent color schemes

The accent colors are defined in JavaScript (_main.js) and injected as CSS variables. Each scheme must be checked:

// Current accent colors from _main.js
const colorSchemes = {
  'ocean-purple': { primary: '#52adc8', accent: '#8B5CF6' },    // Needs fixing
  'emerald-cyan': { primary: '#10b981', accent: '#06b6d4' },    // Check contrast
  'amber-red':    { primary: '#f59e0b', accent: '#ef4444' },    // Check contrast
  'pink-purple':  { primary: '#ec4899', accent: '#8b5cf6' },    // Check contrast
  'indigo-teal':  { primary: '#6366f1', accent: '#14b8a6' }     // Check contrast
};

Recommended fixes for light mode:

Scheme Current Primary Fixed Primary Current Accent Fixed Accent
Ocean Purple #52adc8 #3a8fa8 #8B5CF6 #7c3aed
Emerald Cyan #10b981 #059669 #06b6d4 #0891b2
Amber Red #f59e0b #d97706 #ef4444 #dc2626
Pink Purple #ec4899 #db2777 #8b5cf6 #7c3aed
Indigo Teal #6366f1 #4f46e5 #14b8a6 #0d9488

Rule of thumb: If a color is used as text on a white background, it needs a contrast ratio of at least 4.5:1. Use WebAIM Contrast Checker to verify each.


4. Keyboard Navigation Enhancements

Current State

File: assets/js/_main.js

  • Mobile nav toggle updates aria-expanded (good)
  • Sidebar follow button updates aria-expanded (good)
  • No keyboard trap prevention for modals
  • Color picker swatches lack keyboard arrow navigation
  • Scroll-to-top button is keyboard accessible

Implementation

Add Escape key to close mobile nav

// assets/js/_main.js - add to initMobileNav()

document.addEventListener('keydown', function(e) {
  if (e.key === 'Escape' && nav.classList.contains('is-visible')) {
    nav.classList.remove('is-visible');
    toggle.setAttribute('aria-expanded', 'false');
    toggle.focus(); // Return focus to toggle button
  }
});

Add keyboard navigation to color picker

// assets/js/_main.js - add to color picker initialization

const swatches = document.querySelectorAll('.color-swatch');
swatches.forEach((swatch, index) => {
  swatch.setAttribute('role', 'radio');
  swatch.setAttribute('tabindex', index === 0 ? '0' : '-1');

  swatch.addEventListener('keydown', function(e) {
    let nextIndex;
    if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
      e.preventDefault();
      nextIndex = (index + 1) % swatches.length;
    } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
      e.preventDefault();
      nextIndex = (index - 1 + swatches.length) % swatches.length;
    } else if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      swatch.click();
      return;
    } else {
      return;
    }

    swatches[index].setAttribute('tabindex', '-1');
    swatches[nextIndex].setAttribute('tabindex', '0');
    swatches[nextIndex].focus();
  });
});

Also add role="radiogroup" to the color picker container:

<!-- _includes/masthead.html -->
<div class="color-picker" role="radiogroup" aria-label="Accent color">

Focus management after Swup page transitions

// assets/js/_main.js - add to Swup page:view hook

swup.hooks.on('page:view', function() {
  // Move focus to the main content area after navigation
  const main = document.getElementById('main');
  if (main) {
    main.setAttribute('tabindex', '-1');
    main.focus({ preventScroll: true });
  }
});

5. ARIA Live Regions

Current State

No aria-live regions exist. When the language or theme is toggled, screen readers receive no announcement.

Implementation

Add a live region announcer

<!-- _layouts/default.html - add before </body> -->
<div id="sr-announcer" class="visually-hidden" aria-live="polite" aria-atomic="true"></div>

Announce theme changes

// assets/js/_main.js - add to theme toggle handler

function announce(message) {
  const announcer = document.getElementById('sr-announcer');
  if (announcer) {
    announcer.textContent = message;
    // Clear after announcement is read
    setTimeout(() => { announcer.textContent = ''; }, 1000);
  }
}

// In theme toggle:
announce(isDark ? 'Dark mode enabled' : 'Light mode enabled');

// In language toggle:
announce(lang === 'es' ? 'Idioma cambiado a español' : 'Language changed to English');

// In accent color change:
announce('Accent color changed to ' + colorName);

6. Image Alt Text Audit

Current State

File: _includes/archive-single.html (line 73)

Teaser images have empty alt="":

<img src="..." alt="" loading="lazy">

File: _includes/sidebar.html (line 14)

Sidebar images have conditional alt text that may be empty:

alt=""

Implementation

Fix archive teaser alt text

<!-- _includes/archive-single.html - update img tag -->
<img src="..."
  alt="Project thumbnail"
  loading="lazy"
  decoding="async">

Fix sidebar images

<!-- _includes/sidebar.html - provide fallback alt -->
alt="Sidebar image"

Ensure gallery items in front matter always include alt keys:

# In portfolio/publication front matter
gallery1:
  - url: webp/image.webp
    image_path: webp/image.webp
    alt: "Descriptive alt text for the image"  # Always provide this

Decorative images

For purely decorative images (gradient orbs, dividers), ensure they have:

<img src="decorative.png" alt="" role="presentation">

7. Language Toggle Accessibility

Current State

File: _includes/masthead.html (line 36)

<button class="lang-toggle" aria-label="Toggle language" title="Toggle language">

Issues:

  • No indication of the currently active language
  • Screen reader users don’t know which language is selected
  • The button label doesn’t describe the action clearly

Implementation

Update button with current state

<!-- _includes/masthead.html -->
<button class="lang-toggle"
        aria-label="Switch language"
        aria-pressed="false"
        data-lang-label-en="Switch to Spanish"
        data-lang-label-es="Cambiar a inglés"
        title="Switch language">
  <span class="lang-en" aria-hidden="true">EN</span>
  <span class="lang-es" aria-hidden="true">ES</span>
</button>

Update JavaScript to manage state

// assets/js/_main.js - in language toggle handler

langToggle.addEventListener('click', function() {
  const currentLang = document.documentElement.getAttribute('data-lang');
  const newLang = currentLang === 'en' ? 'es' : 'en';

  document.documentElement.setAttribute('data-lang', newLang);

  // Update accessible label
  const label = newLang === 'en'
    ? this.dataset.langLabelEn
    : this.dataset.langLabelEs;
  this.setAttribute('aria-label', label);

  // Announce change
  announce(newLang === 'es' ? 'Idioma cambiado a español' : 'Language changed to English');

  localStorage.setItem('language', newLang);
});

8. Color Picker Accessibility

Current State

File: _includes/masthead.html (lines 40-49)

<button class="color-toggle" aria-label="Change accent color" title="Change accent color">
  <i class="fas fa-palette" aria-hidden="true"></i>
</button>
<div class="color-picker" aria-hidden="true">
  <button class="color-swatch" data-color="ocean-purple" aria-label="Ocean Purple" style="..."></button>
  <!-- ... 4 more swatches -->
</div>

Issues:

  • Swatches lack role="radio" for group selection semantics
  • No aria-checked to indicate the active color
  • No keyboard arrow navigation between swatches
  • aria-hidden="true" on picker doesn’t toggle when opened

Implementation

<!-- _includes/masthead.html - updated color picker -->
<button class="color-toggle"
        aria-label="Change accent color"
        aria-expanded="false"
        aria-controls="color-picker">
  <i class="fas fa-palette" aria-hidden="true"></i>
</button>

<div id="color-picker" class="color-picker" role="radiogroup" aria-label="Accent color scheme" hidden>
  <button class="color-swatch" role="radio" aria-checked="true"
          data-color="ocean-purple" aria-label="Ocean Purple"
          tabindex="0" style="..."></button>
  <button class="color-swatch" role="radio" aria-checked="false"
          data-color="emerald-cyan" aria-label="Emerald Cyan"
          tabindex="-1" style="..."></button>
  <button class="color-swatch" role="radio" aria-checked="false"
          data-color="amber-red" aria-label="Amber Red"
          tabindex="-1" style="..."></button>
  <button class="color-swatch" role="radio" aria-checked="false"
          data-color="pink-purple" aria-label="Pink Purple"
          tabindex="-1" style="..."></button>
  <button class="color-swatch" role="radio" aria-checked="false"
          data-color="indigo-teal" aria-label="Indigo Teal"
          tabindex="-1" style="..."></button>
</div>

JavaScript updates:

// Toggle picker visibility
colorToggle.addEventListener('click', function() {
  const picker = document.getElementById('color-picker');
  const isHidden = picker.hidden;
  picker.hidden = !isHidden;
  this.setAttribute('aria-expanded', String(isHidden));

  if (isHidden) {
    // Focus the currently selected swatch
    const checked = picker.querySelector('[aria-checked="true"]');
    if (checked) checked.focus();
  }
});

// Update aria-checked on color selection
function selectColor(swatch) {
  document.querySelectorAll('.color-swatch').forEach(s => {
    s.setAttribute('aria-checked', 'false');
    s.setAttribute('tabindex', '-1');
  });
  swatch.setAttribute('aria-checked', 'true');
  swatch.setAttribute('tabindex', '0');
}

9. Lightbox Accessibility

Current State

File: assets/js/_main.js (lines 160-166)

The lightbox uses a native <dialog> element (good), but needs verification:

// Creates dialog dynamically
const dialog = document.createElement('dialog');
dialog.classList.add('lightbox');
// ... adds image ...
document.body.appendChild(dialog);
dialog.showModal();

Implementation

Ensure the lightbox is fully accessible:

// assets/js/_main.js - enhanced lightbox

function openLightbox(imgSrc, imgAlt) {
  const dialog = document.createElement('dialog');
  dialog.classList.add('lightbox');
  dialog.setAttribute('aria-label', imgAlt || 'Image preview');

  const img = document.createElement('img');
  img.src = imgSrc;
  img.alt = imgAlt || '';

  const closeBtn = document.createElement('button');
  closeBtn.className = 'lightbox__close';
  closeBtn.setAttribute('aria-label', 'Close image preview');
  closeBtn.innerHTML = '<i class="fas fa-times" aria-hidden="true"></i>';
  closeBtn.addEventListener('click', () => dialog.close());

  dialog.appendChild(closeBtn);
  dialog.appendChild(img);
  document.body.appendChild(dialog);

  dialog.showModal();
  closeBtn.focus(); // Focus close button on open

  // Announce to screen readers
  announce('Image preview opened: ' + (imgAlt || 'image'));

  dialog.addEventListener('close', function() {
    dialog.remove();
    // Return focus to the trigger element
    if (this._trigger) this._trigger.focus();
    announce('Image preview closed');
  });

  // Close on backdrop click (native dialog behavior)
  dialog.addEventListener('click', function(e) {
    if (e.target === dialog) dialog.close();
  });
}

Testing Checklist

After implementing, verify with these tools:

Automated Testing

# Install axe-cli
npm install -g @axe-core/cli

# Run accessibility audit
axe https://efrenrodriguezrodriguez.com --tags wcag2a,wcag2aa

# Or use Lighthouse
npx lighthouse https://efrenrodriguezrodriguez.com --only-categories=accessibility

Manual Testing

  • Tab through entire page - all interactive elements reachable
  • Skip link visible on first Tab press
  • Focus indicators visible on every interactive element
  • Escape closes mobile nav and color picker
  • Screen reader announces theme/language changes
  • Color picker navigable with arrow keys
  • Lightbox traps focus and returns focus on close
  • All images have meaningful alt text
  • Site usable with 200% browser zoom
  • Content readable with Windows High Contrast mode

Screen Readers to Test With

  • macOS: VoiceOver (built-in, Cmd+F5)
  • Windows: NVDA (free) or JAWS
  • Chrome: ChromeVox extension

Priority Matrix

Improvement Impact Effort Priority
Skip navigation link High Low P0
Focus indicator consistency High Medium P0
Color contrast fixes High Low P0
Image alt text Medium Low P1
ARIA live regions Medium Low P1
Keyboard nav (Escape key) Medium Low P1
Language toggle state Medium Low P1
Color picker a11y Medium Medium P2
Lightbox accessibility Medium Medium P2
Focus management after Swup Low Medium P2