Files
swissfini/themes/swissfini/assets/js/accessibility.js
olaf bda1791fa5 Complete frontend overhaul with WCAG 2.2 accessibility
Design System:
- Custom Hugo theme "swissfini" with editorial aesthetic
- CSS custom properties for comprehensive theming
- Light, Dark, and High Contrast themes
- Print-optimized styles

Accessibility Self-Service Controls:
- Font size adjustment (5 levels: 75%-150%)
- Theme toggle (Light/Dark/High Contrast/System)
- Dyslexia-friendly font (OpenDyslexic)
- Line spacing control (4 levels)
- Reduced motion toggle
- Reading width control (3 levels)
- Enhanced focus indicators
- All preferences persisted via localStorage

Templates & Components:
- Base layout with skip-links and accessibility panel
- Article template with drop caps and blockquotes
- Irony box and conclusion shortcodes
- Responsive header with mobile navigation

Content:
- Migrated SCION vs SD-WAN analysis from HTML
- Homepage teaser with paywall-style CTA

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 07:18:22 +00:00

661 lines
18 KiB
JavaScript

/**
* SwissFini.sh Accessibility Controller
* WCAG 2.2 compliant self-service accessibility controls
*
* Features:
* - Font size adjustment (5 levels: 75%-150%)
* - Theme toggle (light/dark/high-contrast/system)
* - Dyslexia-friendly font toggle
* - Line spacing control (4 levels)
* - Reduced motion toggle
* - Reading width control (3 levels)
* - Enhanced focus indicators toggle
* - All preferences persisted in localStorage
*/
(function () {
'use strict';
// ============================================
// Configuration
// ============================================
const STORAGE_KEY = 'swissfini_accessibility';
const FIRST_VISIT_KEY = 'swissfini_first_visit';
const DEFAULTS = {
fontSize: 3, // 1-5 scale, 3 = 100%
theme: 'system', // 'light', 'dark', 'high-contrast', 'system'
dyslexiaFont: false,
lineSpacing: 'normal', // 'compact', 'normal', 'relaxed', 'loose'
reducedMotion: 'system', // 'true', 'false', 'system'
readingWidth: 'medium', // 'narrow', 'medium', 'wide'
enhancedFocus: false
};
const FONT_SIZES = {
1: 0.75, // 75%
2: 0.875, // 87.5%
3: 1, // 100%
4: 1.25, // 125%
5: 1.5 // 150%
};
const FONT_SIZE_LABELS = {
1: '75%',
2: '88%',
3: '100%',
4: '125%',
5: '150%'
};
// ============================================
// State Management
// ============================================
let state = { ...DEFAULTS };
function loadPreferences() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
state = { ...DEFAULTS, ...parsed };
}
} catch (e) {
console.warn('Could not load accessibility preferences:', e);
}
return state;
}
function savePreferences() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (e) {
console.warn('Could not save accessibility preferences:', e);
}
}
function isFirstVisit() {
try {
return !localStorage.getItem(FIRST_VISIT_KEY);
} catch (e) {
return true;
}
}
function markVisited() {
try {
localStorage.setItem(FIRST_VISIT_KEY, 'true');
} catch (e) {
// Silent fail
}
}
// ============================================
// DOM Manipulation
// ============================================
const html = document.documentElement;
function applyFontSize(level) {
html.setAttribute('data-font-size', level);
// Update label if exists
const label = document.getElementById('font-size-label');
if (label) {
label.textContent = FONT_SIZE_LABELS[level] || '100%';
}
}
function applyTheme(theme) {
// Remove existing theme
html.removeAttribute('data-theme');
let effectiveTheme = theme;
if (theme === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
effectiveTheme = prefersDark ? 'dark' : 'light';
}
if (effectiveTheme !== 'light') {
html.setAttribute('data-theme', effectiveTheme);
}
// Update meta theme-color
const metaTheme = document.querySelector('meta[name="theme-color"]');
if (metaTheme) {
const colors = {
light: '#faf9f7',
dark: '#0d0d0f',
'high-contrast': '#000000'
};
metaTheme.setAttribute('content', colors[effectiveTheme] || colors.light);
}
}
function applyDyslexiaFont(enabled) {
html.setAttribute('data-dyslexia', enabled.toString());
}
function applyLineSpacing(spacing) {
html.setAttribute('data-line-spacing', spacing);
}
function applyReducedMotion(preference) {
if (preference === 'system') {
html.removeAttribute('data-reduced-motion');
} else {
html.setAttribute('data-reduced-motion', preference);
}
}
function applyReadingWidth(width) {
html.setAttribute('data-reading-width', width);
}
function applyEnhancedFocus(enabled) {
html.setAttribute('data-enhanced-focus', enabled.toString());
}
function applyAllPreferences() {
applyFontSize(state.fontSize);
applyTheme(state.theme);
applyDyslexiaFont(state.dyslexiaFont);
applyLineSpacing(state.lineSpacing);
applyReducedMotion(state.reducedMotion);
applyReadingWidth(state.readingWidth);
applyEnhancedFocus(state.enhancedFocus);
}
// ============================================
// Event Handlers
// ============================================
function handleFontSizeChange(direction) {
const newSize = Math.max(1, Math.min(5, state.fontSize + direction));
if (newSize !== state.fontSize) {
state.fontSize = newSize;
applyFontSize(newSize);
savePreferences();
updateControlStates();
announceChange(`Font size changed to ${FONT_SIZE_LABELS[newSize]}`);
}
}
function handleThemeChange(theme) {
state.theme = theme;
applyTheme(theme);
savePreferences();
updateControlStates();
const themeNames = {
light: 'light mode',
dark: 'dark mode',
'high-contrast': 'high contrast mode',
system: 'system preference'
};
announceChange(`Theme changed to ${themeNames[theme]}`);
}
function handleDyslexiaToggle() {
state.dyslexiaFont = !state.dyslexiaFont;
applyDyslexiaFont(state.dyslexiaFont);
savePreferences();
updateControlStates();
announceChange(`Dyslexia-friendly font ${state.dyslexiaFont ? 'enabled' : 'disabled'}`);
}
function handleLineSpacingChange(spacing) {
state.lineSpacing = spacing;
applyLineSpacing(spacing);
savePreferences();
updateControlStates();
announceChange(`Line spacing set to ${spacing}`);
}
function handleReducedMotionChange(preference) {
state.reducedMotion = preference;
applyReducedMotion(preference);
savePreferences();
updateControlStates();
const labels = {
true: 'enabled',
false: 'disabled',
system: 'set to system preference'
};
announceChange(`Reduced motion ${labels[preference]}`);
}
function handleReadingWidthChange(width) {
state.readingWidth = width;
applyReadingWidth(width);
savePreferences();
updateControlStates();
announceChange(`Reading width set to ${width}`);
}
function handleEnhancedFocusToggle() {
state.enhancedFocus = !state.enhancedFocus;
applyEnhancedFocus(state.enhancedFocus);
savePreferences();
updateControlStates();
announceChange(`Enhanced focus indicators ${state.enhancedFocus ? 'enabled' : 'disabled'}`);
}
function handleResetPreferences() {
state = { ...DEFAULTS };
applyAllPreferences();
savePreferences();
updateControlStates();
announceChange('All accessibility preferences reset to defaults');
}
// ============================================
// Screen Reader Announcements
// ============================================
let announcer = null;
function createAnnouncer() {
announcer = document.createElement('div');
announcer.id = 'accessibility-announcer';
announcer.setAttribute('role', 'status');
announcer.setAttribute('aria-live', 'polite');
announcer.setAttribute('aria-atomic', 'true');
announcer.className = 'accessibility-announcer';
document.body.appendChild(announcer);
}
function announceChange(message) {
if (!announcer) createAnnouncer();
// Clear and re-announce for screen readers
announcer.textContent = '';
requestAnimationFrame(() => {
announcer.textContent = message;
});
}
// ============================================
// Panel Toggle & Focus Management
// ============================================
let lastFocusedElement = null;
function initPanelToggle() {
const toggleBtn = document.getElementById('accessibility-toggle');
const panel = document.getElementById('accessibility-panel');
const closeBtn = document.getElementById('accessibility-close');
const backdrop = document.getElementById('accessibility-backdrop');
if (!toggleBtn || !panel) return;
function openPanel() {
lastFocusedElement = document.activeElement;
panel.classList.add('is-open');
panel.setAttribute('aria-hidden', 'false');
toggleBtn.setAttribute('aria-expanded', 'true');
if (backdrop) {
backdrop.classList.add('is-visible');
}
// Focus first focusable element
const firstFocusable = panel.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (firstFocusable) {
setTimeout(() => firstFocusable.focus(), 50);
}
// Add event listeners
document.addEventListener('keydown', handlePanelKeydown);
}
function closePanel() {
panel.classList.remove('is-open');
panel.setAttribute('aria-hidden', 'true');
toggleBtn.setAttribute('aria-expanded', 'false');
if (backdrop) {
backdrop.classList.remove('is-visible');
}
// Restore focus
if (lastFocusedElement) {
lastFocusedElement.focus();
}
// Remove event listeners
document.removeEventListener('keydown', handlePanelKeydown);
}
function handlePanelKeydown(e) {
// Close on Escape
if (e.key === 'Escape') {
closePanel();
return;
}
// Focus trapping
if (e.key === 'Tab') {
const focusables = panel.querySelectorAll(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
if (focusables.length === 0) return;
const firstFocusable = focusables[0];
const lastFocusable = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable.focus();
} else if (!e.shiftKey && document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable.focus();
}
}
}
// Toggle button click
toggleBtn.addEventListener('click', () => {
const isOpen = panel.classList.contains('is-open');
if (isOpen) {
closePanel();
} else {
openPanel();
}
});
// Close button click
if (closeBtn) {
closeBtn.addEventListener('click', closePanel);
}
// Backdrop click
if (backdrop) {
backdrop.addEventListener('click', closePanel);
}
// Remove first-visit animation after interaction
toggleBtn.addEventListener('click', () => {
toggleBtn.classList.remove('is-new');
markVisited();
}, { once: true });
}
// ============================================
// Control State Synchronization
// ============================================
function updateControlStates() {
// Font size buttons
const decreaseBtn = document.getElementById('font-size-decrease');
const increaseBtn = document.getElementById('font-size-increase');
const fontLabel = document.getElementById('font-size-label');
if (decreaseBtn) decreaseBtn.disabled = state.fontSize <= 1;
if (increaseBtn) increaseBtn.disabled = state.fontSize >= 5;
if (fontLabel) fontLabel.textContent = FONT_SIZE_LABELS[state.fontSize];
// Theme buttons
document.querySelectorAll('[data-theme-option]').forEach(btn => {
const isActive = btn.dataset.themeOption === state.theme;
btn.setAttribute('aria-pressed', isActive);
});
// Dyslexia toggle
const dyslexiaBtn = document.getElementById('dyslexia-toggle');
if (dyslexiaBtn) {
dyslexiaBtn.setAttribute('aria-pressed', state.dyslexiaFont);
}
// Line spacing buttons
document.querySelectorAll('[data-spacing-option]').forEach(btn => {
const isActive = btn.dataset.spacingOption === state.lineSpacing;
btn.setAttribute('aria-pressed', isActive);
});
// Reduced motion buttons
document.querySelectorAll('[data-motion-option]').forEach(btn => {
const isActive = btn.dataset.motionOption === state.reducedMotion;
btn.setAttribute('aria-pressed', isActive);
});
// Reading width buttons
document.querySelectorAll('[data-width-option]').forEach(btn => {
const isActive = btn.dataset.widthOption === state.readingWidth;
btn.setAttribute('aria-pressed', isActive);
});
// Enhanced focus toggle
const focusBtn = document.getElementById('enhanced-focus-toggle');
if (focusBtn) {
focusBtn.setAttribute('aria-pressed', state.enhancedFocus);
}
}
// ============================================
// Event Binding
// ============================================
function bindEvents() {
// Font size
const decreaseBtn = document.getElementById('font-size-decrease');
const increaseBtn = document.getElementById('font-size-increase');
if (decreaseBtn) {
decreaseBtn.addEventListener('click', () => handleFontSizeChange(-1));
}
if (increaseBtn) {
increaseBtn.addEventListener('click', () => handleFontSizeChange(1));
}
// Theme
document.querySelectorAll('[data-theme-option]').forEach(btn => {
btn.addEventListener('click', () => handleThemeChange(btn.dataset.themeOption));
});
// Dyslexia
const dyslexiaBtn = document.getElementById('dyslexia-toggle');
if (dyslexiaBtn) {
dyslexiaBtn.addEventListener('click', handleDyslexiaToggle);
}
// Line spacing
document.querySelectorAll('[data-spacing-option]').forEach(btn => {
btn.addEventListener('click', () => handleLineSpacingChange(btn.dataset.spacingOption));
});
// Reduced motion
document.querySelectorAll('[data-motion-option]').forEach(btn => {
btn.addEventListener('click', () => handleReducedMotionChange(btn.dataset.motionOption));
});
// Reading width
document.querySelectorAll('[data-width-option]').forEach(btn => {
btn.addEventListener('click', () => handleReadingWidthChange(btn.dataset.widthOption));
});
// Enhanced focus
const focusBtn = document.getElementById('enhanced-focus-toggle');
if (focusBtn) {
focusBtn.addEventListener('click', handleEnhancedFocusToggle);
}
// Reset
const resetBtn = document.getElementById('accessibility-reset');
if (resetBtn) {
resetBtn.addEventListener('click', handleResetPreferences);
}
// System preference changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (state.theme === 'system') {
applyTheme('system');
}
});
window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', () => {
if (state.reducedMotion === 'system') {
applyReducedMotion('system');
}
});
}
// ============================================
// Header Scroll Effect
// ============================================
function initHeaderScroll() {
const header = document.querySelector('.site-header');
if (!header) return;
let lastScroll = 0;
const scrollThreshold = 10;
function handleScroll() {
const currentScroll = window.scrollY;
if (currentScroll > scrollThreshold) {
header.classList.add('scrolled');
} else {
header.classList.remove('scrolled');
}
lastScroll = currentScroll;
}
window.addEventListener('scroll', handleScroll, { passive: true });
}
// ============================================
// Mobile Navigation
// ============================================
function initMobileNav() {
const toggle = document.querySelector('.nav-toggle');
const nav = document.querySelector('.main-nav');
if (!toggle || !nav) return;
toggle.addEventListener('click', () => {
const isOpen = nav.classList.contains('is-open');
nav.classList.toggle('is-open');
toggle.setAttribute('aria-expanded', !isOpen);
});
// Close on click outside
document.addEventListener('click', (e) => {
if (!nav.contains(e.target) && !toggle.contains(e.target)) {
nav.classList.remove('is-open');
toggle.setAttribute('aria-expanded', 'false');
}
});
// Close on escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && nav.classList.contains('is-open')) {
nav.classList.remove('is-open');
toggle.setAttribute('aria-expanded', 'false');
toggle.focus();
}
});
}
// ============================================
// Initialization
// ============================================
function init() {
loadPreferences();
applyAllPreferences();
// Wait for DOM
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', onDOMReady);
} else {
onDOMReady();
}
}
function onDOMReady() {
bindEvents();
initPanelToggle();
initHeaderScroll();
initMobileNav();
updateControlStates();
// Show first-visit animation
if (isFirstVisit()) {
const toggleBtn = document.getElementById('accessibility-toggle');
if (toggleBtn) {
toggleBtn.classList.add('is-new');
}
}
}
// ============================================
// Critical Styles (Prevent Flash)
// ============================================
(function applyCriticalStyles() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const prefs = JSON.parse(stored);
// Apply theme immediately
if (prefs.theme && prefs.theme !== 'light') {
if (prefs.theme === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (prefersDark) {
html.setAttribute('data-theme', 'dark');
}
} else {
html.setAttribute('data-theme', prefs.theme);
}
}
// Apply font scale
if (prefs.fontSize && FONT_SIZES[prefs.fontSize]) {
html.setAttribute('data-font-size', prefs.fontSize);
}
// Apply dyslexia font
if (prefs.dyslexiaFont) {
html.setAttribute('data-dyslexia', 'true');
}
// Apply other critical preferences
if (prefs.lineSpacing) {
html.setAttribute('data-line-spacing', prefs.lineSpacing);
}
if (prefs.readingWidth) {
html.setAttribute('data-reading-width', prefs.readingWidth);
}
if (prefs.enhancedFocus) {
html.setAttribute('data-enhanced-focus', 'true');
}
if (prefs.reducedMotion && prefs.reducedMotion !== 'system') {
html.setAttribute('data-reduced-motion', prefs.reducedMotion);
}
}
} catch (e) {
// Silent fail
}
})();
// Start
init();
})();