mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
✨ 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:
parent
6cc9f70a4a
commit
02c82c7547
33 changed files with 1474 additions and 143 deletions
132
packages/shared-theme/src/a11y-constants.ts
Normal file
132
packages/shared-theme/src/a11y-constants.ts
Normal 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;
|
||||
192
packages/shared-theme/src/a11y-store.svelte.ts
Normal file
192
packages/shared-theme/src/a11y-store.svelte.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
312
packages/shared-theme/src/a11y-utils.ts
Normal file
312
packages/shared-theme/src/a11y-utils.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue