feat(a11y): add accessibility settings and theme improvements

Add comprehensive accessibility support across shared packages:
- A11y store with contrast, colorblind mode, and reduce motion settings
- A11yQuickToggles and A11ySettings UI components
- PillNavigation and PillDropdown components in shared-ui
- Calendar app updates to integrate new theme/a11y features

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-02 22:56:09 +01:00
parent 6cc9f70a4a
commit 02c82c7547
33 changed files with 1474 additions and 143 deletions

View file

@ -0,0 +1,132 @@
import type { A11ySettings, ContrastLevel, ColorblindMode } from './types';
/**
* localStorage key suffix for A11y settings
*/
export const A11Y_STORAGE_KEY_SUFFIX = '-a11y';
/**
* Default A11y settings
*/
export const DEFAULT_A11Y_SETTINGS: A11ySettings = {
contrast: 'normal',
colorblind: 'none',
reduceMotion: false,
};
/**
* Colorblind mode options for UI
*/
export const COLORBLIND_OPTIONS: readonly {
value: ColorblindMode;
label: string;
description: string;
}[] = [
{
value: 'none',
label: 'Keine',
description: 'Standardfarben',
},
{
value: 'deuteranopia',
label: 'Deuteranopie',
description: 'Rot-Grün-Schwäche (häufigste Form)',
},
{
value: 'protanopia',
label: 'Protanopie',
description: 'Rot-Blindheit',
},
{
value: 'monochrome',
label: 'Monochrom',
description: 'Graustufen',
},
] as const;
/**
* Contrast level options for UI
*/
export const CONTRAST_OPTIONS: readonly {
value: ContrastLevel;
label: string;
description: string;
}[] = [
{
value: 'normal',
label: 'Normal',
description: 'Standard-Kontrast (WCAG AA)',
},
{
value: 'high',
label: 'Hoch',
description: 'Erhöhter Kontrast (WCAG AAA)',
},
] as const;
/**
* High contrast transformation config
* Adjusts lightness values to achieve WCAG AAA (7:1) contrast ratios
*/
export const HIGH_CONTRAST_CONFIG = {
light: {
/** Minimum lightness for backgrounds (push towards white) */
backgroundLightnessMin: 95,
/** Maximum lightness for foregrounds (push towards black) */
foregroundLightnessMax: 15,
/** How much to darken borders */
borderDarken: 15,
/** Minimum saturation boost for primary colors */
primarySaturationMin: 70,
},
dark: {
/** Maximum lightness for backgrounds (push towards black) */
backgroundLightnessMax: 8,
/** Minimum lightness for foregrounds (push towards white) */
foregroundLightnessMin: 90,
/** How much to lighten borders */
borderLighten: 15,
/** Minimum saturation boost for primary colors */
primarySaturationMin: 70,
},
} as const;
/**
* Colorblind transformation configs
* Hue shifts to make colors more distinguishable for each condition
*/
export const COLORBLIND_TRANSFORMS = {
deuteranopia: {
/** Shift problematic green hues towards blue */
hueRangeStart: 80,
hueRangeEnd: 160,
hueShift: 60,
saturationScale: 0.85,
},
protanopia: {
/** Shift problematic red hues towards yellow */
hueRangeStart: 0,
hueRangeEnd: 30,
hueShift: 30,
/** Also handle wrap-around (330-360) */
hueRangeStart2: 330,
hueRangeEnd2: 360,
saturationScale: 0.85,
},
monochrome: {
/** Remove all saturation */
saturationScale: 0,
},
} as const;
/**
* Default animation/transition durations
*/
export const MOTION_DEFAULTS = {
/** Default animation duration in ms */
animationDuration: 300,
/** Default transition duration in ms */
transitionDuration: 200,
/** Reduced (0) for prefers-reduced-motion */
reducedDuration: 0,
} as const;

View file

@ -0,0 +1,192 @@
import type { A11yStore, A11yStoreConfig, ContrastLevel, ColorblindMode } from './types';
import { DEFAULT_A11Y_SETTINGS, A11Y_STORAGE_KEY_SUFFIX } from './a11y-constants';
import {
getSystemReducedMotion,
createReducedMotionListener,
applyA11yAttributes,
loadA11yFromStorage,
saveA11yToStorage,
} from './a11y-utils';
import { isBrowser } from './utils';
/**
* Create an A11y store for your app
*
* @example
* ```typescript
* import { createA11yStore } from '@manacore/shared-theme';
*
* export const a11y = createA11yStore({ appId: 'myapp' });
*
* // In +layout.svelte
* onMount(() => {
* const cleanup = a11y.initialize();
* return cleanup;
* });
* ```
*/
export function createA11yStore(config: A11yStoreConfig): A11yStore {
const { appId, defaults = {} } = config;
const storageKey = `${appId}${A11Y_STORAGE_KEY_SUFFIX}`;
// Merge defaults
const defaultSettings = { ...DEFAULT_A11Y_SETTINGS, ...defaults };
// Svelte 5 runes state
let contrast = $state<ContrastLevel>(defaultSettings.contrast);
let colorblind = $state<ColorblindMode>(defaultSettings.colorblind);
let userReduceMotion = $state<boolean | null>(null); // null = use system
let systemReduceMotion = $state<boolean>(false);
// Derived: effective reduce motion
const reduceMotion = $derived(userReduceMotion !== null ? userReduceMotion : systemReduceMotion);
// Derived: whether user has explicitly set reduce motion
const reduceMotionExplicit = $derived(userReduceMotion !== null);
/**
* Apply current A11y settings to document
*/
function applySettings(): void {
applyA11yAttributes({
contrast,
colorblind,
reduceMotion,
});
saveSettings();
}
/**
* Save settings to localStorage
*/
function saveSettings(): void {
saveA11yToStorage(storageKey, {
contrast,
colorblind,
reduceMotion: userReduceMotion !== null ? userReduceMotion : false,
});
}
/**
* Set contrast level
*/
function setContrast(level: ContrastLevel): void {
if (level === contrast) return;
if (level !== 'normal' && level !== 'high') {
console.warn(`Invalid contrast level: ${level}`);
return;
}
contrast = level;
applySettings();
}
/**
* Set colorblind mode
*/
function setColorblind(mode: ColorblindMode): void {
if (mode === colorblind) return;
const validModes: ColorblindMode[] = ['none', 'deuteranopia', 'protanopia', 'monochrome'];
if (!validModes.includes(mode)) {
console.warn(`Invalid colorblind mode: ${mode}`);
return;
}
colorblind = mode;
applySettings();
}
/**
* Set reduce motion preference
*/
function setReduceMotion(reduce: boolean): void {
userReduceMotion = reduce;
applySettings();
}
/**
* Reset reduce motion to system default
*/
function resetReduceMotion(): void {
userReduceMotion = null;
applySettings();
}
/**
* Reset all A11y settings to defaults
*/
function resetAll(): void {
contrast = defaultSettings.contrast;
colorblind = defaultSettings.colorblind;
userReduceMotion = null;
applySettings();
}
/**
* Initialize A11y store
* - Loads saved preferences from localStorage
* - Sets up reduced motion listener
* - Applies initial settings
*
* @returns Cleanup function to remove listeners
*/
function initialize(): () => void {
if (!isBrowser()) {
return () => {};
}
// Get system reduced motion preference
systemReduceMotion = getSystemReducedMotion();
// Load saved preferences
const saved = loadA11yFromStorage(storageKey);
if (saved) {
if (saved.contrast && (saved.contrast === 'normal' || saved.contrast === 'high')) {
contrast = saved.contrast;
}
if (saved.colorblind) {
const validModes: ColorblindMode[] = ['none', 'deuteranopia', 'protanopia', 'monochrome'];
if (validModes.includes(saved.colorblind as ColorblindMode)) {
colorblind = saved.colorblind as ColorblindMode;
}
}
if (typeof saved.reduceMotion === 'boolean') {
userReduceMotion = saved.reduceMotion;
}
}
// Apply initial settings
applySettings();
// Listen for system reduced motion changes
const cleanup = createReducedMotionListener((reduces) => {
systemReduceMotion = reduces;
// Only re-apply if user hasn't explicitly set a preference
if (userReduceMotion === null) {
applySettings();
}
});
return cleanup;
}
return {
get contrast() {
return contrast;
},
get colorblind() {
return colorblind;
},
get reduceMotion() {
return reduceMotion;
},
get reduceMotionExplicit() {
return reduceMotionExplicit;
},
setContrast,
setColorblind,
setReduceMotion,
resetReduceMotion,
resetAll,
initialize,
};
}

View file

@ -0,0 +1,312 @@
import type {
ThemeColors,
EffectiveMode,
HSLValue,
ContrastLevel,
ColorblindMode,
A11ySettings,
} from './types';
import { parseHSL, createHSL, isBrowser } from './utils';
import { HIGH_CONTRAST_CONFIG, COLORBLIND_TRANSFORMS, MOTION_DEFAULTS } from './a11y-constants';
// ============================================================================
// Reduced Motion
// ============================================================================
/**
* Check if system prefers reduced motion
*/
export function getSystemReducedMotion(): boolean {
if (!isBrowser()) return false;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
/**
* Create a media query listener for reduced motion preference changes
*/
export function createReducedMotionListener(callback: (reduces: boolean) => void): () => void {
if (!isBrowser()) return () => {};
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const handler = (e: MediaQueryListEvent) => callback(e.matches);
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}
mediaQuery.addListener(handler);
return () => mediaQuery.removeListener(handler);
}
/**
* Apply motion settings to document
*/
export function applyMotionSettings(reduceMotion: boolean): void {
if (!isBrowser()) return;
const root = document.documentElement;
if (reduceMotion) {
root.setAttribute('data-reduce-motion', 'true');
root.style.setProperty('--animation-duration', `${MOTION_DEFAULTS.reducedDuration}ms`);
root.style.setProperty('--transition-duration', `${MOTION_DEFAULTS.reducedDuration}ms`);
} else {
root.removeAttribute('data-reduce-motion');
root.style.setProperty('--animation-duration', `${MOTION_DEFAULTS.animationDuration}ms`);
root.style.setProperty('--transition-duration', `${MOTION_DEFAULTS.transitionDuration}ms`);
}
}
// ============================================================================
// High Contrast Transformations
// ============================================================================
/**
* Color role classification for contrast adjustments
*/
type ColorRole = 'background' | 'foreground' | 'border' | 'primary' | 'semantic' | 'other';
/**
* Get the role of a color based on its key
*/
function getColorRole(colorKey: keyof ThemeColors): ColorRole {
const backgrounds = ['background', 'surface', 'surfaceHover', 'surfaceElevated', 'muted', 'input'];
const foregrounds = ['foreground', 'primaryForeground', 'secondaryForeground', 'mutedForeground'];
const borders = ['border', 'borderStrong', 'ring'];
const primaries = ['primary', 'secondary'];
const semantics = ['error', 'success', 'warning'];
if (backgrounds.includes(colorKey)) return 'background';
if (foregrounds.includes(colorKey)) return 'foreground';
if (borders.includes(colorKey)) return 'border';
if (primaries.includes(colorKey)) return 'primary';
if (semantics.includes(colorKey)) return 'semantic';
return 'other';
}
/**
* Apply high contrast transformation to a single color
*/
function applyHighContrastToColor(
hsl: HSLValue,
colorKey: keyof ThemeColors,
mode: EffectiveMode
): HSLValue {
const { h, s, l } = parseHSL(hsl);
const role = getColorRole(colorKey);
let newL = l;
let newS = s;
if (mode === 'light') {
const config = HIGH_CONTRAST_CONFIG.light;
switch (role) {
case 'background':
newL = Math.max(l, config.backgroundLightnessMin);
break;
case 'foreground':
newL = Math.min(l, config.foregroundLightnessMax);
break;
case 'border':
newL = Math.max(0, l - config.borderDarken);
break;
case 'primary':
case 'semantic':
newS = Math.max(s, config.primarySaturationMin);
newL = Math.min(l, 45);
break;
}
} else {
const config = HIGH_CONTRAST_CONFIG.dark;
switch (role) {
case 'background':
newL = Math.min(l, config.backgroundLightnessMax);
break;
case 'foreground':
newL = Math.max(l, config.foregroundLightnessMin);
break;
case 'border':
newL = Math.min(100, l + config.borderLighten);
break;
case 'primary':
case 'semantic':
newS = Math.max(s, config.primarySaturationMin);
newL = Math.max(l, 55);
break;
}
}
return createHSL(h, newS, newL);
}
/**
* Apply high contrast transformations to all theme colors
*/
export function applyHighContrast(colors: ThemeColors, mode: EffectiveMode): ThemeColors {
const result = { ...colors };
for (const key of Object.keys(colors) as (keyof ThemeColors)[]) {
result[key] = applyHighContrastToColor(colors[key], key, mode);
}
return result;
}
// ============================================================================
// Colorblind Transformations
// ============================================================================
/**
* Shift hue within a range
*/
function shiftHueInRange(
h: number,
rangeStart: number,
rangeEnd: number,
shift: number
): number {
if (h >= rangeStart && h <= rangeEnd) {
return (h + shift) % 360;
}
return h;
}
/**
* Apply colorblind transformation to a single color
*/
function applyColorblindToColor(hsl: HSLValue, mode: ColorblindMode): HSLValue {
if (mode === 'none') return hsl;
const { h, s, l } = parseHSL(hsl);
if (mode === 'monochrome') {
// Full grayscale - remove all saturation
return createHSL(h, 0, l);
}
if (mode === 'deuteranopia') {
const config = COLORBLIND_TRANSFORMS.deuteranopia;
const newH = shiftHueInRange(h, config.hueRangeStart, config.hueRangeEnd, config.hueShift);
const newS = s * config.saturationScale;
return createHSL(newH, newS, l);
}
if (mode === 'protanopia') {
const config = COLORBLIND_TRANSFORMS.protanopia;
let newH = shiftHueInRange(h, config.hueRangeStart, config.hueRangeEnd, config.hueShift);
// Also handle wrap-around reds (330-360)
newH = shiftHueInRange(newH, config.hueRangeStart2, config.hueRangeEnd2, config.hueShift);
const newS = s * config.saturationScale;
return createHSL(newH, newS, l);
}
return hsl;
}
/**
* Apply colorblind transformations to all theme colors
*/
export function applyColorblindTransform(
colors: ThemeColors,
mode: ColorblindMode
): ThemeColors {
if (mode === 'none') return colors;
const result = { ...colors };
for (const key of Object.keys(colors) as (keyof ThemeColors)[]) {
result[key] = applyColorblindToColor(colors[key], mode);
}
return result;
}
// ============================================================================
// Combined A11y Application
// ============================================================================
/**
* Apply all A11y transformations to theme colors
*/
export function applyA11yTransformations(
colors: ThemeColors,
mode: EffectiveMode,
a11ySettings: A11ySettings
): ThemeColors {
let result = { ...colors };
// Apply high contrast first (if enabled)
if (a11ySettings.contrast === 'high') {
result = applyHighContrast(result, mode);
}
// Apply colorblind transformation
if (a11ySettings.colorblind !== 'none') {
result = applyColorblindTransform(result, a11ySettings.colorblind);
}
return result;
}
/**
* Apply A11y data attributes to document
*/
export function applyA11yAttributes(a11ySettings: A11ySettings): void {
if (!isBrowser()) return;
const root = document.documentElement;
// Contrast level
if (a11ySettings.contrast === 'high') {
root.setAttribute('data-contrast', 'high');
} else {
root.removeAttribute('data-contrast');
}
// Colorblind mode
if (a11ySettings.colorblind !== 'none') {
root.setAttribute('data-colorblind', a11ySettings.colorblind);
} else {
root.removeAttribute('data-colorblind');
}
// Motion settings
applyMotionSettings(a11ySettings.reduceMotion);
}
// ============================================================================
// Storage
// ============================================================================
/**
* Load A11y settings from localStorage
*/
export function loadA11yFromStorage(storageKey: string): Partial<A11ySettings> | null {
if (!isBrowser()) return null;
try {
const stored = localStorage.getItem(storageKey);
if (stored) {
return JSON.parse(stored);
}
} catch (e) {
console.warn('Failed to load A11y settings from storage:', e);
}
return null;
}
/**
* Save A11y settings to localStorage
*/
export function saveA11yToStorage(storageKey: string, settings: A11ySettings): void {
if (!isBrowser()) return;
try {
localStorage.setItem(storageKey, JSON.stringify(settings));
} catch (e) {
console.warn('Failed to save A11y settings to storage:', e);
}
}

View file

@ -9,6 +9,12 @@ export type {
AppThemeConfig,
ThemeStore,
HSLValue,
// A11y Types
ContrastLevel,
ColorblindMode,
A11ySettings,
A11yStore,
A11yStoreConfig,
} from './types';
// Constants
@ -21,9 +27,23 @@ export {
STORAGE_KEY_SUFFIX,
} from './constants';
// A11y Constants
export {
A11Y_STORAGE_KEY_SUFFIX,
DEFAULT_A11Y_SETTINGS,
COLORBLIND_OPTIONS,
CONTRAST_OPTIONS,
HIGH_CONTRAST_CONFIG,
COLORBLIND_TRANSFORMS,
MOTION_DEFAULTS,
} from './a11y-constants';
// Store
export { createThemeStore, APP_THEME_CONFIGS } from './store.svelte';
// A11y Store
export { createA11yStore } from './a11y-store.svelte';
// Utils
export {
isBrowser,
@ -41,3 +61,16 @@ export {
getContrastColor,
generateThemeCSS,
} from './utils';
// A11y Utils
export {
getSystemReducedMotion,
createReducedMotionListener,
applyMotionSettings,
applyHighContrast,
applyColorblindTransform,
applyA11yTransformations,
applyA11yAttributes,
loadA11yFromStorage,
saveA11yToStorage,
} from './a11y-utils';

View file

@ -134,3 +134,72 @@ export interface ThemeStore {
/** Initialize theme (call on mount) */
initialize: () => () => void;
}
// ============================================================================
// Accessibility (A11y) Types
// ============================================================================
/**
* Contrast level for accessibility
* - 'normal': Default contrast (WCAG AA 4.5:1 minimum)
* - 'high': Enhanced contrast (WCAG AAA 7:1 minimum)
*/
export type ContrastLevel = 'normal' | 'high';
/**
* Colorblind mode simulation/adaptation
* - 'none': No colorblind adaptation
* - 'deuteranopia': Green-blind (most common, ~6% of males)
* - 'protanopia': Red-blind (~1% of males)
* - 'monochrome': Full grayscale (achromatopsia)
*/
export type ColorblindMode = 'none' | 'deuteranopia' | 'protanopia' | 'monochrome';
/**
* A11y settings state
*/
export interface A11ySettings {
/** Contrast level */
contrast: ContrastLevel;
/** Colorblind adaptation mode */
colorblind: ColorblindMode;
/** Reduce motion preference */
reduceMotion: boolean;
}
/**
* A11y store interface (separate from ThemeStore)
*/
export interface A11yStore {
/** Current contrast level */
readonly contrast: ContrastLevel;
/** Current colorblind mode */
readonly colorblind: ColorblindMode;
/** Effective reduce motion (user setting OR system preference) */
readonly reduceMotion: boolean;
/** Whether user has explicitly set reduce motion (vs system default) */
readonly reduceMotionExplicit: boolean;
/** Set contrast level */
setContrast: (level: ContrastLevel) => void;
/** Set colorblind mode */
setColorblind: (mode: ColorblindMode) => void;
/** Set reduce motion preference */
setReduceMotion: (reduce: boolean) => void;
/** Reset to system default for reduce motion */
resetReduceMotion: () => void;
/** Reset all A11y settings to defaults */
resetAll: () => void;
/** Initialize (call on mount) */
initialize: () => () => void;
}
/**
* A11y store configuration
*/
export interface A11yStoreConfig {
/** App identifier for localStorage key */
appId: string;
/** Default settings override */
defaults?: Partial<A11ySettings>;
}

View file

@ -1,5 +1,6 @@
import type { ThemeColors, ThemeVariant, EffectiveMode, HSLValue } from './types';
import type { ThemeColors, ThemeVariant, EffectiveMode, HSLValue, A11ySettings } from './types';
import { THEME_DEFINITIONS, CSS_VAR_PREFIX } from './constants';
import { applyA11yTransformations, applyA11yAttributes } from './a11y-utils';
/**
* Check if code is running in browser
@ -90,12 +91,20 @@ export function colorsToCssVars(colors: ThemeColors): Record<string, string> {
export function applyThemeToDocument(
variant: ThemeVariant,
effectiveMode: EffectiveMode,
primaryOverride?: { light: HSLValue; dark: HSLValue }
primaryOverride?: { light: HSLValue; dark: HSLValue },
a11ySettings?: A11ySettings
): void {
if (!isBrowser()) return;
const root = document.documentElement;
const colors = getThemeColors(variant, effectiveMode, primaryOverride);
let colors = getThemeColors(variant, effectiveMode, primaryOverride);
// Apply A11y transformations if provided
if (a11ySettings) {
colors = applyA11yTransformations(colors, effectiveMode, a11ySettings);
applyA11yAttributes(a11ySettings);
}
const cssVars = colorsToCssVars(colors);
// Set CSS variables