mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19: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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
114
packages/shared-theme-ui/src/components/A11yQuickToggles.svelte
Normal file
114
packages/shared-theme-ui/src/components/A11yQuickToggles.svelte
Normal 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>
|
||||
188
packages/shared-theme-ui/src/components/A11ySettings.svelte
Normal file
188
packages/shared-theme-ui/src/components/A11ySettings.svelte
Normal 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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue