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

@ -9,6 +9,7 @@ pnpm docker:down
pnpm dev:chat:app
pnpm dev:contacts:app
pnpm dev:storage:app
pnpm dev:calendar:app
pnpm dev:picture:app
pnpm dev:manacore:app

View file

@ -19,7 +19,14 @@ export class CalendarController {
@Get()
async findAll(@CurrentUser() user: CurrentUserData) {
const calendars = await this.calendarService.findAll(user.userId);
let calendars = await this.calendarService.findAll(user.userId);
// Lazy creation: if no calendars exist, create a default one
if (calendars.length === 0) {
const defaultCalendar = await this.calendarService.getOrCreateDefaultCalendar(user.userId);
calendars = [defaultCalendar];
}
return { calendars };
}

View file

@ -116,7 +116,7 @@ export class CalendarService {
// Create a new default calendar
return this.create(userId, {
name: 'My Calendar',
name: 'Mein Kalender',
isDefault: true,
color: '#3B82F6',
});

View file

@ -40,18 +40,18 @@
/* Hour slot in day/week view */
.hour-slot {
height: var(--hour-height);
border-bottom: 1px solid hsl(var(--border) / 0.5);
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
position: relative;
}
.hour-slot:hover {
background-color: hsl(var(--muted) / 0.3);
background-color: hsl(var(--color-muted) / 0.3);
}
/* Event card in calendar */
.event-card {
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
background-color: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-radius: var(--radius-sm);
padding: 2px 6px;
font-size: 0.75rem;
@ -68,17 +68,17 @@
/* Day cell in month view */
.day-cell {
min-height: 100px;
border: 1px solid hsl(var(--border));
border: 1px solid hsl(var(--color-border));
padding: var(--spacing-xs);
transition: background-color var(--transition-fast);
}
.day-cell:hover {
background-color: hsl(var(--muted) / 0.3);
background-color: hsl(var(--color-muted) / 0.3);
}
.day-cell.today {
background-color: hsl(var(--primary) / 0.1);
background-color: hsl(var(--color-primary) / 0.1);
}
.day-cell.other-month {
@ -91,7 +91,7 @@
left: 0;
right: 0;
height: 2px;
background-color: hsl(var(--destructive));
background-color: hsl(var(--color-error));
z-index: 10;
}
@ -103,7 +103,7 @@
width: 10px;
height: 10px;
border-radius: 50%;
background-color: hsl(var(--destructive));
background-color: hsl(var(--color-error));
}
/* Mini calendar */
@ -123,24 +123,24 @@
}
.mini-calendar .day:hover {
background-color: hsl(var(--muted));
background-color: hsl(var(--color-muted));
}
.mini-calendar .day.today {
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
background-color: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.mini-calendar .day.selected {
border: 2px solid hsl(var(--primary));
border: 2px solid hsl(var(--color-primary));
}
/* Card styles */
.card {
background-color: hsl(var(--card));
background-color: hsl(var(--color-surface));
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
border: 1px solid hsl(var(--border));
border: 1px solid hsl(var(--color-border));
}
/* Button styles */
@ -159,12 +159,12 @@
}
.btn-primary {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.btn-primary:hover {
background: hsl(var(--primary) / 0.9);
background: hsl(var(--color-primary) / 0.9);
}
.btn-primary:disabled {
@ -173,21 +173,21 @@
}
.btn-secondary {
background: hsl(var(--secondary));
color: hsl(var(--secondary-foreground));
background: hsl(var(--color-secondary));
color: hsl(var(--color-secondary-foreground));
}
.btn-secondary:hover {
background: hsl(var(--secondary) / 0.8);
background: hsl(var(--color-secondary) / 0.8);
}
.btn-ghost {
background: transparent;
color: hsl(var(--foreground));
color: hsl(var(--color-foreground));
}
.btn-ghost:hover {
background: hsl(var(--muted));
background: hsl(var(--color-muted));
}
.btn-icon {
@ -204,21 +204,21 @@
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
border: 2px solid hsl(var(--border));
border: 2px solid hsl(var(--color-border));
border-radius: var(--radius-md);
background-color: hsl(var(--background));
color: hsl(var(--foreground));
background-color: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
transition: border-color var(--transition-fast);
}
.input:focus {
outline: none;
border-color: hsl(var(--primary));
border-color: hsl(var(--color-primary));
}
.input::placeholder {
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
}
/* Select styling */
@ -233,7 +233,7 @@ select.input {
/* Text colors */
.text-destructive {
color: hsl(var(--destructive));
color: hsl(var(--color-error));
}
/* Scrollbar styling */

View file

@ -85,8 +85,8 @@
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid hsl(var(--border));
background: hsl(var(--card));
border-bottom: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
}
.header-left {
@ -103,7 +103,7 @@
.header-title {
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--foreground));
color: hsl(var(--color-foreground));
margin: 0;
}
@ -115,7 +115,7 @@
.view-selector {
display: flex;
background: hsl(var(--muted));
background: hsl(var(--color-muted));
border-radius: var(--radius-md);
padding: 0.25rem;
}
@ -127,18 +127,18 @@
border-radius: var(--radius-sm);
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: all 150ms ease;
}
.view-btn:hover {
color: hsl(var(--foreground));
color: hsl(var(--color-foreground));
}
.view-btn.active {
background: hsl(var(--background));
color: hsl(var(--foreground));
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}

View file

@ -43,9 +43,9 @@
<style>
.calendar-sidebar-section {
background: hsl(var(--card));
background: hsl(var(--color-surface));
border-radius: var(--radius-lg);
border: 1px solid hsl(var(--border));
border: 1px solid hsl(var(--color-border));
padding: 1rem;
}
@ -59,7 +59,7 @@
.section-title {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--foreground));
color: hsl(var(--color-foreground));
margin: 0;
}
@ -69,12 +69,12 @@
background: transparent;
border-radius: var(--radius-sm);
cursor: pointer;
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
}
.add-btn:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.calendar-list {
@ -93,7 +93,7 @@
}
.calendar-item:hover {
background: hsl(var(--muted) / 0.5);
background: hsl(var(--color-muted) / 0.5);
}
.calendar-item input {
@ -110,12 +110,12 @@
.calendar-name {
font-size: 0.875rem;
color: hsl(var(--foreground));
color: hsl(var(--color-foreground));
}
.empty-message {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
text-align: center;
padding: 1rem;
}

View file

@ -137,13 +137,13 @@
.all-day-section {
display: flex;
border-bottom: 1px solid hsl(var(--border));
border-bottom: 1px solid hsl(var(--color-border));
padding: 0.5rem 0;
}
.all-day-label {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
}
.all-day-events {
@ -179,7 +179,7 @@
padding-right: 0.5rem;
text-align: right;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
position: relative;
top: -0.5em;
}
@ -194,11 +194,11 @@
.day-column {
flex: 1;
position: relative;
border-left: 1px solid hsl(var(--border));
border-left: 1px solid hsl(var(--color-border));
}
.day-column.today {
background: hsl(var(--primary) / 0.05);
background: hsl(var(--color-primary) / 0.05);
}
.event-card {

View file

@ -103,12 +103,12 @@
background: transparent;
border-radius: var(--radius-sm);
cursor: pointer;
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
}
.nav-btn:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.weekday-row {
@ -120,7 +120,7 @@
.weekday {
text-align: center;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
font-weight: 500;
}
@ -141,25 +141,25 @@
font-size: 0.75rem;
border: none;
background: transparent;
color: hsl(var(--foreground));
color: hsl(var(--color-foreground));
transition: all var(--transition-fast);
}
.day:hover {
background: hsl(var(--muted));
background: hsl(var(--color-muted));
}
.day.other-month {
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
opacity: 0.5;
}
.day.today {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.day.selected {
border: 2px solid hsl(var(--primary));
border: 2px solid hsl(var(--color-primary));
}
</style>

View file

@ -126,7 +126,7 @@
.weekday-headers {
display: grid;
grid-template-columns: repeat(7, 1fr);
border-bottom: 1px solid hsl(var(--border));
border-bottom: 1px solid hsl(var(--color-border));
}
.weekday-header {
@ -134,7 +134,7 @@
text-align: center;
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
}
@ -152,7 +152,7 @@
}
.day-cell {
border: 1px solid hsl(var(--border));
border: 1px solid hsl(var(--color-border));
border-top: none;
border-left: none;
padding: var(--spacing-xs);
@ -165,15 +165,15 @@
}
.day-cell:first-child {
border-left: 1px solid hsl(var(--border));
border-left: 1px solid hsl(var(--color-border));
}
.day-cell:hover {
background-color: hsl(var(--muted) / 0.3);
background-color: hsl(var(--color-muted) / 0.3);
}
.day-cell.today {
background-color: hsl(var(--primary) / 0.1);
background-color: hsl(var(--color-primary) / 0.1);
}
.day-cell.other-month {
@ -193,8 +193,8 @@
}
.day-number.today {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.day-events {
@ -232,7 +232,7 @@
.more-events {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
background: transparent;
border: none;
cursor: pointer;
@ -241,6 +241,6 @@
}
.more-events:hover {
color: hsl(var(--primary));
color: hsl(var(--color-primary));
}
</style>

View file

@ -162,7 +162,7 @@
.all-day-row {
display: flex;
border-bottom: 1px solid hsl(var(--border));
border-bottom: 1px solid hsl(var(--color-border));
min-height: 32px;
}
@ -172,7 +172,7 @@
flex-wrap: wrap;
gap: 2px;
padding: 4px;
border-left: 1px solid hsl(var(--border));
border-left: 1px solid hsl(var(--color-border));
}
.all-day-event {
@ -189,7 +189,7 @@
.day-headers {
display: flex;
border-bottom: 1px solid hsl(var(--border));
border-bottom: 1px solid hsl(var(--color-border));
}
.time-gutter {
@ -203,12 +203,12 @@
flex-direction: column;
align-items: center;
padding: 0.5rem;
border-left: 1px solid hsl(var(--border));
border-left: 1px solid hsl(var(--color-border));
}
.day-name {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
}
@ -224,8 +224,8 @@
}
.day-number.today {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.time-grid {
@ -244,7 +244,7 @@
padding-right: 0.5rem;
text-align: right;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
position: relative;
top: -0.5em;
}
@ -257,11 +257,11 @@
.day-column {
flex: 1;
position: relative;
border-left: 1px solid hsl(var(--border));
border-left: 1px solid hsl(var(--color-border));
}
.day-column.today {
background: hsl(var(--primary) / 0.05);
background: hsl(var(--color-primary) / 0.05);
}
.event-card {

View file

@ -18,7 +18,14 @@
let description = $state(event?.description || '');
let location = $state(event?.location || '');
let isAllDay = $state(event?.isAllDay || false);
let calendarId = $state(event?.calendarId || calendarsStore.defaultCalendar?.id || '');
let calendarId = $state(event?.calendarId || '');
// Set default calendar when calendars are loaded
$effect(() => {
if (!calendarId && calendarsStore.defaultCalendar?.id) {
calendarId = calendarsStore.defaultCalendar.id;
}
});
// Date/time handling
let startDate = $state('');
@ -58,6 +65,7 @@
e.preventDefault();
if (!title.trim()) return;
if (!calendarId) return;
const startDateTime = new Date(`${startDate}T${isAllDay ? '00:00' : startTime}`);
const endDateTime = new Date(`${endDate}T${isAllDay ? '23:59' : endTime}`);
@ -158,7 +166,7 @@
<button type="button" class="px-4 py-2 rounded-lg font-medium text-foreground bg-transparent hover:bg-muted transition-colors" onclick={onCancel}>
Abbrechen
</button>
<button type="submit" class="px-4 py-2 rounded-lg font-medium text-primary-foreground bg-primary hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" disabled={submitting || !title.trim()}>
<button type="submit" class="px-4 py-2 rounded-lg font-medium text-primary-foreground bg-primary hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" disabled={submitting || !title.trim() || !calendarId}>
{mode === 'create' ? 'Erstellen' : 'Speichern'}
</button>
</div>

View file

@ -56,7 +56,9 @@ export const calendarsStore = {
error = result.error.message;
calendars = [];
} else {
calendars = result.data || [];
// API returns { calendars: [...] }
const data = result.data as { calendars: Calendar[] } | null;
calendars = data?.calendars || [];
}
loading = false;
@ -70,7 +72,9 @@ export const calendarsStore = {
const result = await api.createCalendar(data);
if (result.data) {
calendars = [...calendars, result.data];
// API returns { calendar: {...} }
const responseData = result.data as { calendar: Calendar };
calendars = [...calendars, responseData.calendar];
}
return result;
@ -83,7 +87,9 @@ export const calendarsStore = {
const result = await api.updateCalendar(id, data);
if (result.data) {
calendars = getCalendarsArray().map((c) => (c.id === id ? result.data! : c));
// API returns { calendar: {...} }
const responseData = result.data as { calendar: Calendar };
calendars = getCalendarsArray().map((c) => (c.id === id ? responseData.calendar : c));
}
return result;

View file

@ -13,9 +13,9 @@ let error = $state<string | null>(null);
let loadedRange = $state<{ start: Date; end: Date } | null>(null);
export const eventsStore = {
// Getters
// Getters - always return safe values
get events() {
return events;
return events ?? [];
},
get loading() {
return loading;
@ -40,7 +40,9 @@ export const eventsStore = {
if (result.error) {
error = result.error.message;
} else {
events = result.data || [];
// API returns { events: [...] }
const data = result.data as { events: CalendarEvent[] } | null;
events = data?.events || [];
loadedRange = { start: startDate, end: endDate };
}
@ -94,7 +96,9 @@ export const eventsStore = {
const result = await api.createEvent(data);
if (result.data) {
events = [...events, result.data];
// API returns { event: {...} }
const responseData = result.data as { event: CalendarEvent };
events = [...events, responseData.event];
}
return result;
@ -107,7 +111,9 @@ export const eventsStore = {
const result = await api.updateEvent(id, data);
if (result.data) {
events = events.map((e) => (e.id === id ? result.data! : e));
// API returns { event: {...} }
const responseData = result.data as { event: CalendarEvent };
events = events.map((e) => (e.id === id ? responseData.event : e));
}
return result;

View file

@ -77,7 +77,7 @@
const navItems: PillNavItem[] = [
{ href: '/', label: 'Kalender', icon: 'calendar' },
{ href: '/agenda', label: 'Agenda', icon: 'list' },
{ href: '/calendars', label: 'Meine Kalender', icon: 'document' },
{ href: '/calendars', label: 'Meine Kalender', icon: 'folder' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];

View file

@ -107,9 +107,9 @@
display: flex;
flex-direction: column;
min-width: 0;
background: hsl(var(--card));
background: hsl(var(--color-surface));
border-radius: var(--radius-lg);
border: 1px solid hsl(var(--border));
border: 1px solid hsl(var(--color-border));
overflow: hidden;
}

View file

@ -11,9 +11,13 @@
// Group events by date
let groupedEvents = $derived.by(() => {
const groups: Map<string, typeof eventsStore.events> = new Map();
// Safety check: ensure events is an array
const currentEvents = eventsStore.events ?? [];
if (!Array.isArray(currentEvents)) return [];
for (const event of eventsStore.events) {
const groups: Map<string, typeof currentEvents> = new Map();
for (const event of currentEvents) {
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const dateKey = format(start, 'yyyy-MM-dd');
@ -132,19 +136,19 @@
.page-header h1 {
font-size: 1.75rem;
font-weight: 700;
color: hsl(var(--foreground));
color: hsl(var(--color-foreground));
margin: 0 0 0.25rem 0;
}
.subtitle {
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.loading {
text-align: center;
padding: 2rem;
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
}
.empty-state {
@ -153,7 +157,7 @@
}
.empty-state p {
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
margin-bottom: 1rem;
}
@ -172,7 +176,7 @@
.date-header {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0;
@ -180,7 +184,7 @@
}
.date-header.today {
color: hsl(var(--primary));
color: hsl(var(--color-primary));
}
.event-item {
@ -211,13 +215,13 @@
.event-time {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
margin-bottom: 0.25rem;
}
.event-title {
font-weight: 500;
color: hsl(var(--foreground));
color: hsl(var(--color-foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@ -225,7 +229,7 @@
.event-location {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
margin-top: 0.25rem;
}
</style>

View file

@ -220,7 +220,7 @@
width: 48px;
height: 42px;
padding: 4px;
border: 2px solid hsl(var(--border));
border: 2px solid hsl(var(--color-border));
border-radius: var(--radius-md);
cursor: pointer;
}
@ -263,9 +263,9 @@
.badge {
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
background: hsl(var(--muted));
background: hsl(var(--color-muted));
border-radius: var(--radius-sm);
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
}
.calendar-actions {
@ -279,12 +279,12 @@
}
.text-destructive {
color: hsl(var(--destructive));
color: hsl(var(--color-error));
}
.empty-state {
text-align: center;
padding: 2rem;
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -159,7 +159,7 @@
.page-title {
font-size: 1.5rem;
font-weight: 600;
color: hsl(var(--foreground));
color: hsl(var(--color-foreground));
margin: 0;
}
@ -171,7 +171,7 @@
.loading {
text-align: center;
padding: 2rem;
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
}
.event-details {
@ -189,15 +189,15 @@
.label {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
}
.value {
font-size: 1rem;
color: hsl(var(--foreground));
color: hsl(var(--color-foreground));
}
.text-destructive {
color: hsl(var(--destructive));
color: hsl(var(--color-error));
}
</style>

View file

@ -68,6 +68,6 @@
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: hsl(var(--foreground));
color: hsl(var(--color-foreground));
}
</style>

View file

@ -134,12 +134,12 @@
font-weight: 600;
margin: 0 0 1rem 0;
padding-bottom: 0.75rem;
border-bottom: 1px solid hsl(var(--border));
border-bottom: 1px solid hsl(var(--color-border));
}
.setting-item {
padding: 1rem 0;
border-bottom: 1px solid hsl(var(--border) / 0.5);
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
}
.setting-item:last-child {
@ -156,17 +156,17 @@
.setting-label {
font-weight: 500;
color: hsl(var(--foreground));
color: hsl(var(--color-foreground));
}
.setting-description {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
}
.setting-value {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
}
.theme-options {
@ -179,22 +179,22 @@
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 2px solid hsl(var(--border));
border: 2px solid hsl(var(--color-border));
border-radius: var(--radius-md);
background: transparent;
color: hsl(var(--foreground));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
cursor: pointer;
transition: all 150ms ease;
}
.theme-option:hover {
border-color: hsl(var(--primary) / 0.5);
border-color: hsl(var(--color-primary) / 0.5);
}
.theme-option.active {
border-color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.1);
}
.variant-grid {
@ -209,7 +209,7 @@
align-items: center;
gap: 0.25rem;
padding: 0.75rem;
border: 2px solid hsl(var(--border));
border: 2px solid hsl(var(--color-border));
border-radius: var(--radius-md);
background: transparent;
cursor: pointer;
@ -217,12 +217,12 @@
}
.variant-option:hover {
border-color: hsl(var(--primary) / 0.5);
border-color: hsl(var(--color-primary) / 0.5);
}
.variant-option.active {
border-color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.1);
}
.variant-icon {
@ -231,10 +231,10 @@
.variant-label {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
color: hsl(var(--color-muted-foreground));
}
.text-destructive {
color: hsl(var(--destructive));
color: hsl(var(--color-error));
}
</style>

View file

@ -0,0 +1,114 @@
<script lang="ts">
import type { A11yStore } from '@manacore/shared-theme';
interface Props {
/** A11y store instance */
store: A11yStore;
/** Contrast toggle title */
contrastTitle?: string;
/** Motion toggle title */
motionTitle?: string;
}
let {
store,
contrastTitle = 'Hoher Kontrast',
motionTitle = 'Animationen reduzieren',
}: Props = $props();
</script>
<div class="a11y-quick-toggles">
<!-- Contrast Toggle -->
<button
type="button"
onclick={() => store.setContrast(store.contrast === 'high' ? 'normal' : 'high')}
class="a11y-btn"
class:active={store.contrast === 'high'}
title={contrastTitle}
aria-pressed={store.contrast === 'high'}
>
<svg class="a11y-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 2v20M12 2a10 10 0 0 1 0 20" fill="currentColor" />
</svg>
</button>
<!-- Reduce Motion Toggle -->
<button
type="button"
onclick={() => store.setReduceMotion(!store.reduceMotion)}
class="a11y-btn"
class:active={store.reduceMotion}
title={motionTitle}
aria-pressed={store.reduceMotion}
>
<svg class="a11y-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{#if store.reduceMotion}
<!-- Pause icon when motion is reduced -->
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
{:else}
<!-- Play/motion icon -->
<polygon points="5 3 19 12 5 21 5 3" />
{/if}
</svg>
</button>
</div>
<style>
.a11y-quick-toggles {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem;
border-radius: 9999px;
background: rgba(245, 245, 245, 0.95);
border: 1px solid rgba(0, 0, 0, 0.1);
}
:global(.dark) .a11y-quick-toggles {
background: rgba(40, 40, 40, 0.95);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.a11y-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem;
border: none;
background: transparent;
border-radius: 9999px;
cursor: pointer;
color: #6b7280;
transition: all 0.15s;
}
:global(.dark) .a11y-btn {
color: #9ca3af;
}
.a11y-btn:hover:not(.active) {
background: rgba(0, 0, 0, 0.05);
color: #374151;
}
:global(.dark) .a11y-btn:hover:not(.active) {
background: rgba(255, 255, 255, 0.1);
color: #f3f4f6;
}
.a11y-btn.active {
background: hsl(var(--color-primary) / 0.2);
color: hsl(var(--color-primary));
}
:global(.dark) .a11y-btn.active {
background: hsl(var(--color-primary) / 0.3);
}
.a11y-icon {
width: 1rem;
height: 1rem;
}
</style>

View file

@ -0,0 +1,188 @@
<script lang="ts">
import type { A11yStore, ContrastLevel, ColorblindMode } from '@manacore/shared-theme';
import { COLORBLIND_OPTIONS, CONTRAST_OPTIONS } from '@manacore/shared-theme';
import type { A11yTranslations } from '../types';
import { defaultA11yTranslations } from '../types';
interface Props {
/** A11y store instance */
store: A11yStore;
/** Custom translations */
translations?: Partial<A11yTranslations>;
/** Show reset button */
showReset?: boolean;
}
let { store, translations = {}, showReset = true }: Props = $props();
// Merge translations with defaults
const t = $derived({ ...defaultA11yTranslations, ...translations });
// Colorblind mode labels mapped to translations
const colorblindLabels: Record<ColorblindMode, string> = $derived({
none: t.colorblindNone,
deuteranopia: t.colorblindDeuteranopia,
protanopia: t.colorblindProtanopia,
monochrome: t.colorblindMonochrome,
});
</script>
<div class="a11y-settings space-y-6">
<!-- Contrast Setting -->
<div class="setting-group">
<span class="setting-label">{t.contrastLabel}</span>
<div class="inline-flex rounded-lg bg-muted p-1" role="group" aria-label={t.contrastLabel}>
{#each CONTRAST_OPTIONS as option}
<button
type="button"
onclick={() => store.setContrast(option.value)}
class="flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors
{store.contrast === option.value
? 'bg-surface text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
title={option.description}
>
{option.value === 'normal' ? t.contrastNormal : t.contrastHigh}
</button>
{/each}
</div>
</div>
<!-- Colorblind Mode -->
<div class="setting-group">
<label for="colorblind-select" class="setting-label">{t.colorblindLabel}</label>
<select
id="colorblind-select"
class="select-input"
value={store.colorblind}
onchange={(e) => store.setColorblind(e.currentTarget.value as ColorblindMode)}
>
{#each COLORBLIND_OPTIONS as option}
<option value={option.value}>
{colorblindLabels[option.value]}
</option>
{/each}
</select>
</div>
<!-- Reduce Motion -->
<div class="setting-group">
<div class="flex items-center justify-between">
<div>
<label for="reduce-motion" class="setting-label mb-0">{t.reduceMotionLabel}</label>
<p class="text-sm text-muted-foreground">{t.reduceMotionDescription}</p>
</div>
<div class="flex items-center gap-2">
{#if store.reduceMotionExplicit}
<button
type="button"
onclick={() => store.resetReduceMotion()}
class="text-xs text-muted-foreground hover:text-foreground underline"
>
{t.systemDefault}
</button>
{/if}
<button
id="reduce-motion"
type="button"
role="switch"
aria-checked={store.reduceMotion}
aria-label={t.reduceMotionLabel}
onclick={() => store.setReduceMotion(!store.reduceMotion)}
class="toggle-switch"
class:active={store.reduceMotion}
>
<span class="toggle-thumb" class:active={store.reduceMotion}></span>
</button>
</div>
</div>
</div>
<!-- Reset Button -->
{#if showReset}
<div class="pt-2 border-t border-border">
<button
type="button"
onclick={() => store.resetAll()}
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{t.reset}
</button>
</div>
{/if}
</div>
<style>
.a11y-settings {
width: 100%;
}
.setting-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.setting-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
margin-bottom: 0.25rem;
}
.select-input {
width: 100%;
max-width: 20rem;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-input));
color: hsl(var(--color-foreground));
cursor: pointer;
transition: border-color 0.2s;
}
.select-input:hover {
border-color: hsl(var(--color-border-strong));
}
.select-input:focus {
outline: none;
border-color: hsl(var(--color-ring));
box-shadow: 0 0 0 2px hsl(var(--color-ring) / 0.2);
}
.toggle-switch {
position: relative;
width: 2.75rem;
height: 1.5rem;
border-radius: 9999px;
background: hsl(var(--color-muted));
border: none;
cursor: pointer;
transition: background-color 0.2s;
flex-shrink: 0;
}
.toggle-switch.active {
background: hsl(var(--color-primary));
}
.toggle-thumb {
position: absolute;
top: 0.125rem;
left: 0.125rem;
width: 1.25rem;
height: 1.25rem;
border-radius: 9999px;
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: transform 0.2s;
}
.toggle-thumb.active {
transform: translateX(1.25rem);
}
</style>

View file

@ -3,14 +3,24 @@ export { default as ThemeToggle } from './ThemeToggle.svelte';
export { default as ThemeSelector } from './ThemeSelector.svelte';
export { default as ThemeModeSelector } from './ThemeModeSelector.svelte';
// New Components
// Theme Components
export { default as ThemeColorPreview } from './components/ThemeColorPreview.svelte';
export { default as ThemeCard } from './components/ThemeCard.svelte';
export { default as ThemeGrid } from './components/ThemeGrid.svelte';
// A11y Components
export { default as A11ySettings } from './components/A11ySettings.svelte';
export { default as A11yQuickToggles } from './components/A11yQuickToggles.svelte';
// Pages
export { default as ThemePage } from './pages/ThemePage.svelte';
// Types
export type { ThemeStatus, ThemeCardData, ThemePageProps, ThemePageTranslations } from './types';
export { defaultTranslations } from './types';
export type {
ThemeStatus,
ThemeCardData,
ThemePageProps,
ThemePageTranslations,
A11yTranslations,
} from './types';
export { defaultTranslations, defaultA11yTranslations } from './types';

View file

@ -1,9 +1,10 @@
<script lang="ts">
import type { ThemeVariant, ThemeMode } from '@manacore/shared-theme';
import type { ThemeVariant, ThemeMode, A11yStore } from '@manacore/shared-theme';
import { ArrowLeft, Sun, Moon, Desktop } from '@manacore/shared-icons';
import type { ThemeCardData, ThemePageTranslations } from '../types';
import { defaultTranslations } from '../types';
import type { ThemeCardData, ThemePageTranslations, A11yTranslations } from '../types';
import { defaultTranslations, defaultA11yTranslations } from '../types';
import ThemeGrid from '../components/ThemeGrid.svelte';
import A11ySettings from '../components/A11ySettings.svelte';
interface Props {
// Theme Store Integration
@ -30,6 +31,11 @@
// Translations
translations?: Partial<ThemePageTranslations>;
// A11y Settings
a11yStore?: A11yStore;
showA11ySettings?: boolean;
a11yTranslations?: Partial<A11yTranslations>;
}
let {
@ -46,10 +52,14 @@
showLockedThemes = true,
onUnlockTheme,
translations = {},
a11yStore,
showA11ySettings = false,
a11yTranslations = {},
}: Props = $props();
// Merge translations with defaults
const t = $derived({ ...defaultTranslations, ...translations });
const a11yT = $derived({ ...defaultA11yTranslations, ...a11yTranslations });
const modes: { mode: ThemeMode; icon: typeof Sun; label: string }[] = $derived([
{ mode: 'light', icon: Sun, label: t.lightMode },
@ -121,5 +131,15 @@
{translations}
/>
</section>
<!-- A11y Settings -->
{#if showA11ySettings && a11yStore}
<section class="mt-8 pt-8 border-t border-border">
<h2 class="text-sm font-medium text-muted-foreground mb-4">
{a11yT.a11yTitle}
</h2>
<A11ySettings store={a11yStore} translations={a11yTranslations} />
</section>
{/if}
</div>
</div>

View file

@ -85,3 +85,58 @@ export const defaultTranslations: ThemePageTranslations = {
lightPreview: 'Hell',
darkPreview: 'Dunkel',
};
// ============================================================================
// A11y (Accessibility) Types
// ============================================================================
/**
* Translations for A11y settings
*/
export interface A11yTranslations {
/** Section title */
a11yTitle: string;
/** Contrast setting label */
contrastLabel: string;
/** Normal contrast option */
contrastNormal: string;
/** High contrast option */
contrastHigh: string;
/** Colorblind setting label */
colorblindLabel: string;
/** No colorblind adaptation */
colorblindNone: string;
/** Deuteranopia option */
colorblindDeuteranopia: string;
/** Protanopia option */
colorblindProtanopia: string;
/** Monochrome option */
colorblindMonochrome: string;
/** Reduce motion label */
reduceMotionLabel: string;
/** Reduce motion description */
reduceMotionDescription: string;
/** System default label */
systemDefault: string;
/** Reset button */
reset: string;
}
/**
* Default German A11y translations
*/
export const defaultA11yTranslations: A11yTranslations = {
a11yTitle: 'Barrierefreiheit',
contrastLabel: 'Kontrast',
contrastNormal: 'Normal',
contrastHigh: 'Hoch',
colorblindLabel: 'Farbsehschwäche',
colorblindNone: 'Keine',
colorblindDeuteranopia: 'Rot-Grün (Deuteranopie)',
colorblindProtanopia: 'Rot-Blindheit (Protanopie)',
colorblindMonochrome: 'Monochrom',
reduceMotionLabel: 'Animationen reduzieren',
reduceMotionDescription: 'Deaktiviert Animationen und Übergänge',
systemDefault: 'System',
reset: 'Zurücksetzen',
};

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

View file

@ -11,6 +11,8 @@
onToggle?: (open: boolean) => void;
/** Optional header content (e.g., mode selector) */
header?: Snippet;
/** Optional footer content (e.g., a11y toggles) */
footer?: Snippet;
}
let {
@ -21,6 +23,7 @@
isOpen = false,
onToggle,
header,
footer,
}: Props = $props();
let internalOpen = $state(false);
@ -248,6 +251,13 @@
{/if}
{/if}
{/each}
<!-- Optional footer (e.g., a11y toggles) -->
{#if footer}
<div class="dropdown-footer">
{@render footer()}
</div>
{/if}
</div>
{/if}
</div>
@ -487,4 +497,34 @@
flex: 1;
text-align: left;
}
/* Footer for custom content (e.g., a11y toggles) */
.dropdown-footer {
animation: fanIn 0.15s ease-out forwards;
opacity: 0;
transform: translateY(10px);
position: relative;
z-index: 1;
margin-top: 0.25rem;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
:global(.dark) .dropdown-footer {
border-top-color: rgba(255, 255, 255, 0.15);
}
.fan-up .dropdown-footer {
transform: translateY(-10px);
margin-top: 0;
margin-bottom: 0.25rem;
padding-top: 0;
padding-bottom: 0.5rem;
border-top: none;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
:global(.dark) .fan-up .dropdown-footer {
border-bottom-color: rgba(255, 255, 255, 0.15);
}
</style>

View file

@ -120,6 +120,17 @@
allAppsHref?: string;
/** All Apps label (default: "Alle Apps") */
allAppsLabel?: string;
// A11y Settings
/** A11y contrast level */
a11yContrast?: 'normal' | 'high';
/** Called when a11y contrast changes */
onA11yContrastChange?: (contrast: 'normal' | 'high') => void;
/** A11y reduce motion setting */
a11yReduceMotion?: boolean;
/** Called when a11y reduce motion changes */
onA11yReduceMotionChange?: (reduce: boolean) => void;
/** Show a11y quick toggles in theme dropdown */
showA11yQuickToggles?: boolean;
}
let {
@ -156,6 +167,11 @@
loginHref,
allAppsHref,
allAppsLabel = 'Alle Apps',
a11yContrast = 'normal',
onA11yContrastChange,
a11yReduceMotion = false,
onA11yReduceMotionChange,
showA11yQuickToggles = false,
}: Props = $props();
// Type guards for elements
@ -241,6 +257,10 @@
// Icon SVG paths
const icons: Record<string, string> = {
mic: 'M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z',
calendar:
'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z',
folder:
'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z',
archive: 'M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4',
upload: 'M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12',
music:
@ -447,6 +467,44 @@
</button>
</div>
{/snippet}
{#snippet footer()}
{#if showA11yQuickToggles}
<div class="a11y-quick-toggles glass-pill">
<!-- Contrast Toggle -->
<button
type="button"
onclick={() => onA11yContrastChange?.(a11yContrast === 'high' ? 'normal' : 'high')}
class="a11y-btn"
class:active={a11yContrast === 'high'}
title="Hoher Kontrast"
aria-pressed={a11yContrast === 'high'}
>
<svg class="a11y-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 2v20M12 2a10 10 0 0 1 0 20" fill="currentColor" />
</svg>
</button>
<!-- Reduce Motion Toggle -->
<button
type="button"
onclick={() => onA11yReduceMotionChange?.(!a11yReduceMotion)}
class="a11y-btn"
class:active={a11yReduceMotion}
title="Animationen reduzieren"
aria-pressed={a11yReduceMotion}
>
<svg class="a11y-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{#if a11yReduceMotion}
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
{:else}
<polygon points="5 3 19 12 5 21 5 3" />
{/if}
</svg>
</button>
</div>
{/if}
{/snippet}
</PillDropdown>
{/if}
@ -1167,4 +1225,71 @@
width: 1rem;
height: 1rem;
}
/* A11y quick toggles in dropdown footer */
:global(.a11y-quick-toggles) {
display: flex !important;
align-items: center !important;
gap: 0.25rem !important;
padding: 0.25rem !important;
border-radius: 9999px !important;
background: rgba(245, 245, 245, 0.95) !important;
border: 1px solid rgba(0, 0, 0, 0.1) !important;
color: #374151 !important;
}
:global(.dark .a11y-quick-toggles) {
background: rgba(40, 40, 40, 0.95) !important;
border: 1px solid rgba(255, 255, 255, 0.15) !important;
color: #f3f4f6 !important;
}
:global(.a11y-btn) {
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem;
border: none;
background: transparent;
border-radius: 9999px;
cursor: pointer;
color: #6b7280;
transition: all 0.15s;
}
:global(.dark .a11y-btn) {
color: #9ca3af;
}
:global(.a11y-btn:hover:not(.active)) {
background: rgba(0, 0, 0, 0.05);
color: #374151;
}
:global(.dark .a11y-btn:hover:not(.active)) {
background: rgba(255, 255, 255, 0.1);
color: #f3f4f6;
}
:global(.a11y-btn.active) {
background: color-mix(
in srgb,
var(--pill-primary-color, var(--color-primary-500, #3b82f6)) 20%,
white 80%
);
color: var(--pill-primary-color, var(--color-primary-500, #3b82f6));
}
:global(.dark .a11y-btn.active) {
background: color-mix(
in srgb,
var(--pill-primary-color, var(--color-primary-500, #3b82f6)) 30%,
transparent 70%
);
}
:global(.a11y-icon) {
width: 1rem;
height: 1rem;
}
</style>