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>
661 lines
18 KiB
JavaScript
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();
|
|
})();
|