fix(a11y): Beschriftungen verknuepfen, Reiter umbrechen, Badge-Farbton zurueck

- Alle 14 <label> ohne Bezug haben jetzt htmlFor/id — davon 9 in den
  Fuehrer-Formularen, also genau dort, wo am Handy getippt wird. Die
  GPS-Gruppenueberschrift im Benutzerformular ist keine Feldbeschriftung
  mehr, sondern eine Ueberschrift.
- Das Auf- und Zuklappen im Audit-Log lag auf einem <div> und war mit der
  Tastatur nicht erreichbar; jetzt ein <button> mit aria-expanded, das bei
  fehlenden Details disabled ist. .audit-item-header war ausserdem zweimal
  in derselben Datei definiert — zusammengefasst.
- Die Reiter im Admin-Panel standen in einem inline-flex ohne Umbruch: bei
  360 px war die Leiste 395 px breit und die Seite lief horizontal ueber.
  Die Datei hatte gar keine Media-Query. Jetzt Umbruch, ein Breakpoint fuer
  <=480px und 44 px Mindesthoehe je Reiter.
- REGRESSION der letzten Runde: die geskriptete Token-Migration hat #e8eaf6
  (Indigo-Toenung) als neutrale Flaeche eingestuft, wodurch .badge-bulk-update
  im Hellmodus nicht mehr von .badge-default zu unterscheiden war. Farbton
  zurueckgesetzt.
- .settings-section-count und .settings-rule-index nutzten --color-border als
  Flaeche und lagen bei 4,49:1 — jetzt --color-surface-alt.
- Checkbox, Radio und Schieberegler folgen ueber accent-color der Markenfarbe
  statt dem Browser-Blau.

Geprueft: rechnerischer Kontrast-Durchlauf ueber alle Regeln mit Flaeche und
Text in beiden Ansichten; kein Ueberlauf mehr bei 360 px in Admin-Panel und
Fuehrer-Dashboard, hell wie dunkel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
thomas 2026-08-30 20:47:44 +02:00
parent 87011eee3c
commit e2af87a7e5
24 changed files with 231 additions and 126 deletions

View File

@ -13,6 +13,11 @@
in beiden Varianten nachgerechnet.
*/
:root {
/* Sagt dem Browser, dass die Seite beide Ansichten kann. Native Bedienelemente
Checkboxen, Auswahllisten, Bildlaufleisten passen sich dadurch an,
statt in der Nachtansicht hell aufzublitzen. */
color-scheme: light dark;
--font-display: Georgia, 'Times New Roman', serif;
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
@ -159,9 +164,6 @@ body {
-moz-osx-font-smoothing: grayscale;
background: var(--color-bg);
color: var(--color-text);
/* Damit auch vom Browser gestellte Bedienelemente (Bildlaufleisten,
Datumsauswahl, Autofill) der gewählten Ansicht folgen. */
color-scheme: light dark;
}
/* Formularfelder brauchen ausdrücklich Farben: ohne sie nimmt der Browser
@ -179,6 +181,13 @@ textarea::placeholder {
opacity: 1;
}
/* Checkbox, Radio und Schieberegler in der Markenfarbe statt im Browser-Blau. */
input[type="checkbox"],
input[type="radio"],
input[type="range"] {
accent-color: var(--color-primary);
}
h1, h2, h3 {
font-family: var(--font-display);
}

View File

@ -4,6 +4,9 @@
.admin-tabs {
display: inline-flex;
/* Vier Reiter passen auf schmalen Displays nicht in eine Zeile ohne
Umbruch lief die Seite bei 360 px horizontal ueber. */
flex-wrap: wrap;
gap: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
@ -14,6 +17,7 @@
}
.tab-button {
min-height: var(--touch-target);
padding: 0.5rem 1rem;
border: none;
background: transparent;
@ -122,7 +126,7 @@
.settings-section-count {
font-size: 0.8rem;
color: var(--color-text-muted);
background: var(--color-border);
background: var(--color-surface-alt);
border-radius: 999px;
padding: 0.1rem 0.5rem;
}
@ -164,7 +168,7 @@
font-size: 0.75rem;
font-weight: 700;
color: var(--color-text-muted);
background: var(--color-border);
background: var(--color-surface-alt);
border-radius: 50%;
flex-shrink: 0;
}
@ -346,3 +350,16 @@
opacity: 1;
}
}
@media (max-width: 480px) {
.admin-tabs {
display: flex;
width: 100%;
}
.tab-button {
flex: 1 1 45%;
padding: 0.5rem 0.6rem;
font-size: 0.9rem;
}
}

View File

@ -371,8 +371,8 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
</div>
<div className="settings-section-body">
<div className="settings-field">
<label className="settings-label">Name der App (wird in der Kopfzeile angezeigt)</label>
<input
<label className="settings-label" htmlFor="settings-appname">Name der App (wird in der Kopfzeile angezeigt)</label>
<input id="settings-appname"
type="text"
className="settings-input"
value={config.appName || ''}
@ -439,8 +439,8 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
{ key: 'ansprechpartner', label: 'Ansprechpartner und Koordination' }
].map(({ key, label }) => (
<div key={key} className="settings-field">
<label className="settings-label">{label}</label>
<textarea
<label className="settings-label" htmlFor={`settings-section-${key}`}>{label}</label>
<textarea id={`settings-section-${key}`}
value={getSectionContent(key)}
onChange={(e) => updateSection(key, e.target.value)}
className="settings-textarea settings-textarea-lg"

View File

@ -231,7 +231,7 @@
.badge-login-failed { background: #ffcdd2; color: #b71c1c; font-weight: 700; }
.badge-import { background: #e0f7fa; color: #00695c; }
.badge-export { background: #e8f5e9; color: #1b5e20; }
.badge-bulk-update { background: var(--color-surface-alt); color: #283593; }
.badge-bulk-update { background: #e8eaf6; color: #283593; }
.badge-bulk-delete { background: #fbe9e7; color: #bf360c; }
.badge-password { background: #fff8e1; color: #9c4e00; }
.badge-default { background: #f5f5f5; color: #616161; }
@ -280,14 +280,25 @@
.audit-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.audit-item-failed { border-left: 4px solid var(--color-danger); }
/* Als <button> ausgezeichnet, damit das Auf- und Zuklappen auch mit der
Tastatur erreichbar ist. Die Button-Grundstile werden hier zurueckgesetzt. */
.audit-item-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
flex-wrap: wrap;
width: 100%;
text-align: left;
font: inherit;
color: inherit;
background: var(--color-surface-alt);
border: none;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
}
.audit-item-header:disabled {
cursor: default;
}
.audit-time { margin-left: auto; color: var(--color-text-muted); font-size: 12px; white-space: nowrap; }
.expand-toggle { color: var(--color-text-muted); font-size: 11px; cursor: pointer; padding: 0 4px; }
@ -502,13 +513,6 @@
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.audit-item-header {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
}
.audit-badge {
padding: 6px 12px;

View File

@ -228,8 +228,13 @@ function AuditLogs() {
return (
<div key={log._id} className={`audit-item ${!log.success ? 'audit-item-failed' : ''}`}>
<div className="audit-item-header" onClick={() => hasDetail && toggleExpand(log._id)}
style={{ cursor: hasDetail ? 'pointer' : 'default' }}>
<button
type="button"
className="audit-item-header"
onClick={() => toggleExpand(log._id)}
disabled={!hasDetail}
aria-expanded={hasDetail ? isExpanded : undefined}
>
<span className={`audit-badge ${ACTION_BADGE[log.action] || 'badge-default'}`}>
{ACTION_ICONS[log.action]} {ACTION_LABELS[log.action] || log.action}
</span>
@ -244,7 +249,7 @@ function AuditLogs() {
{hasDetail && (
<span className="expand-toggle">{isExpanded ? '▲' : '▼'}</span>
)}
</div>
</button>
<div className="audit-item-body">
<span className="audit-admin">👤 <strong>{log.adminUsername || '—'}</strong></span>

View File

@ -115,18 +115,18 @@ const DrohnenfuehrerDashboard = ({ drohnenfuehrerUser, onLogout }) => {
) : (
<form onSubmit={handleSave} className="drohnenfuehrer-edit-form">
<div className="form-group">
<label>Adresse</label>
<input type="text" value={formData.address}
<label htmlFor="profil-adresse">Adresse</label>
<input id="profil-adresse" type="text" value={formData.address}
onChange={e => setFormData({ ...formData, address: e.target.value })} required />
</div>
<div className="form-group">
<label>Mobilnummer</label>
<input type="tel" value={formData.phone}
<label htmlFor="profil-mobil">Mobilnummer</label>
<input id="profil-mobil" type="tel" value={formData.phone}
onChange={e => setFormData({ ...formData, phone: e.target.value })} required />
</div>
<div className="form-group">
<label>Festnetz (optional)</label>
<input type="tel" value={formData.landline}
<label htmlFor="profil-festnetz">Festnetz (optional)</label>
<input id="profil-festnetz" type="tel" value={formData.landline}
onChange={e => setFormData({ ...formData, landline: e.target.value })} />
</div>
<div className="drohnenfuehrer-form-actions">

View File

@ -87,12 +87,12 @@ const DrohnenfuehrerLogin = ({ onLogin }) => {
{mode === 'login' ? (
<form onSubmit={handleLogin} className="drohnenfuehrer-form">
<div className="form-group">
<label>E-Mail</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
<label htmlFor="login-email">E-Mail</label>
<input id="login-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
</div>
<div className="form-group">
<label>Passwort</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
<label htmlFor="login-password">Passwort</label>
<input id="login-password" type="password" value={password} onChange={e => setPassword(e.target.value)} required />
</div>
<button type="submit" className="btn-drohnenfuehrer-primary" disabled={loading}>
{loading ? 'Anmelden...' : 'Anmelden'}
@ -104,20 +104,20 @@ const DrohnenfuehrerLogin = ({ onLogin }) => {
Der Admin hat Ihre E-Mail-Adresse hinterlegt. Geben Sie hier Ihre E-Mail, den Einladungs-Token und ein neues Passwort ein.
</p>
<div className="form-group">
<label>E-Mail</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
<label htmlFor="setpw-email">E-Mail</label>
<input id="setpw-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
</div>
<div className="form-group">
<label>Einladungs-Token</label>
<input type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
<label htmlFor="setpw-token">Einladungs-Token</label>
<input id="setpw-token" type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
</div>
<div className="form-group">
<label>Neues Passwort (min. 8 Zeichen)</label>
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} />
<label htmlFor="setpw-password">Neues Passwort (min. 8 Zeichen)</label>
<input id="setpw-password" type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} />
</div>
<div className="form-group">
<label>Passwort wiederholen</label>
<input type="password" value={newPassword2} onChange={e => setNewPassword2(e.target.value)} required />
<label htmlFor="setpw-repeat">Passwort wiederholen</label>
<input id="setpw-repeat" type="password" value={newPassword2} onChange={e => setNewPassword2(e.target.value)} required />
</div>
<button type="submit" className="btn-drohnenfuehrer-primary" disabled={loading}>
{loading ? 'Wird gesetzt...' : 'Passwort setzen'}

View File

@ -20,8 +20,8 @@ const FilterPanel = ({ filters, onFilterChange, showAvailableFilter = true }) =>
</div>
)}
<div className="filter-group">
<label>Typ:</label>
<select
<label htmlFor="filter-typ">Typ:</label>
<select id="filter-typ"
value={filters.type || ''}
onChange={(e) => onFilterChange('type', e.target.value || null)}
className="filter-select"
@ -35,8 +35,8 @@ const FilterPanel = ({ filters, onFilterChange, showAvailableFilter = true }) =>
</select>
</div>
<div className="filter-group">
<label>Sortierung:</label>
<select
<label htmlFor="filter-sortierung">Sortierung:</label>
<select id="filter-sortierung"
value={filters.sortBy || 'name'}
onChange={(e) => onFilterChange('sortBy', e.target.value)}
className="filter-select"

View File

@ -13,6 +13,11 @@
in beiden Varianten nachgerechnet.
*/
:root {
/* Sagt dem Browser, dass die Seite beide Ansichten kann. Native Bedienelemente
Checkboxen, Auswahllisten, Bildlaufleisten passen sich dadurch an,
statt in der Nachtansicht hell aufzublitzen. */
color-scheme: light dark;
--font-display: Georgia, 'Times New Roman', serif;
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
@ -159,9 +164,6 @@ body {
-moz-osx-font-smoothing: grayscale;
background: var(--color-bg);
color: var(--color-text);
/* Damit auch vom Browser gestellte Bedienelemente (Bildlaufleisten,
Datumsauswahl, Autofill) der gewählten Ansicht folgen. */
color-scheme: light dark;
}
/* Formularfelder brauchen ausdrücklich Farben: ohne sie nimmt der Browser
@ -179,6 +181,13 @@ textarea::placeholder {
opacity: 1;
}
/* Checkbox, Radio und Schieberegler in der Markenfarbe statt im Browser-Blau. */
input[type="checkbox"],
input[type="radio"],
input[type="range"] {
accent-color: var(--color-primary);
}
h1, h2, h3 {
font-family: var(--font-display);
}

View File

@ -4,6 +4,9 @@
.admin-tabs {
display: inline-flex;
/* Vier Reiter passen auf schmalen Displays nicht in eine Zeile ohne
Umbruch lief die Seite bei 360 px horizontal ueber. */
flex-wrap: wrap;
gap: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
@ -14,6 +17,7 @@
}
.tab-button {
min-height: var(--touch-target);
padding: 0.5rem 1rem;
border: none;
background: transparent;
@ -122,7 +126,7 @@
.settings-section-count {
font-size: 0.8rem;
color: var(--color-text-muted);
background: var(--color-border);
background: var(--color-surface-alt);
border-radius: 999px;
padding: 0.1rem 0.5rem;
}
@ -164,7 +168,7 @@
font-size: 0.75rem;
font-weight: 700;
color: var(--color-text-muted);
background: var(--color-border);
background: var(--color-surface-alt);
border-radius: 50%;
flex-shrink: 0;
}
@ -346,3 +350,16 @@
opacity: 1;
}
}
@media (max-width: 480px) {
.admin-tabs {
display: flex;
width: 100%;
}
.tab-button {
flex: 1 1 45%;
padding: 0.5rem 0.6rem;
font-size: 0.9rem;
}
}

View File

@ -371,8 +371,8 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
</div>
<div className="settings-section-body">
<div className="settings-field">
<label className="settings-label">Name der App (wird in der Kopfzeile angezeigt)</label>
<input
<label className="settings-label" htmlFor="settings-appname">Name der App (wird in der Kopfzeile angezeigt)</label>
<input id="settings-appname"
type="text"
className="settings-input"
value={config.appName || ''}
@ -439,8 +439,8 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
{ key: 'ansprechpartner', label: 'Ansprechpartner und Koordination' }
].map(({ key, label }) => (
<div key={key} className="settings-field">
<label className="settings-label">{label}</label>
<textarea
<label className="settings-label" htmlFor={`settings-section-${key}`}>{label}</label>
<textarea id={`settings-section-${key}`}
value={getSectionContent(key)}
onChange={(e) => updateSection(key, e.target.value)}
className="settings-textarea settings-textarea-lg"

View File

@ -231,7 +231,7 @@
.badge-login-failed { background: #ffcdd2; color: #b71c1c; font-weight: 700; }
.badge-import { background: #e0f7fa; color: #00695c; }
.badge-export { background: #e8f5e9; color: #1b5e20; }
.badge-bulk-update { background: var(--color-surface-alt); color: #283593; }
.badge-bulk-update { background: #e8eaf6; color: #283593; }
.badge-bulk-delete { background: #fbe9e7; color: #bf360c; }
.badge-password { background: #fff8e1; color: #9c4e00; }
.badge-default { background: #f5f5f5; color: #616161; }
@ -280,14 +280,25 @@
.audit-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.audit-item-failed { border-left: 4px solid var(--color-danger); }
/* Als <button> ausgezeichnet, damit das Auf- und Zuklappen auch mit der
Tastatur erreichbar ist. Die Button-Grundstile werden hier zurueckgesetzt. */
.audit-item-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
flex-wrap: wrap;
width: 100%;
text-align: left;
font: inherit;
color: inherit;
background: var(--color-surface-alt);
border: none;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
}
.audit-item-header:disabled {
cursor: default;
}
.audit-time { margin-left: auto; color: var(--color-text-muted); font-size: 12px; white-space: nowrap; }
.expand-toggle { color: var(--color-text-muted); font-size: 11px; cursor: pointer; padding: 0 4px; }
@ -502,13 +513,6 @@
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.audit-item-header {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
}
.audit-badge {
padding: 6px 12px;

View File

@ -228,8 +228,13 @@ function AuditLogs() {
return (
<div key={log._id} className={`audit-item ${!log.success ? 'audit-item-failed' : ''}`}>
<div className="audit-item-header" onClick={() => hasDetail && toggleExpand(log._id)}
style={{ cursor: hasDetail ? 'pointer' : 'default' }}>
<button
type="button"
className="audit-item-header"
onClick={() => toggleExpand(log._id)}
disabled={!hasDetail}
aria-expanded={hasDetail ? isExpanded : undefined}
>
<span className={`audit-badge ${ACTION_BADGE[log.action] || 'badge-default'}`}>
{ACTION_ICONS[log.action]} {ACTION_LABELS[log.action] || log.action}
</span>
@ -244,7 +249,7 @@ function AuditLogs() {
{hasDetail && (
<span className="expand-toggle">{isExpanded ? '▲' : '▼'}</span>
)}
</div>
</button>
<div className="audit-item-body">
<span className="audit-admin">👤 <strong>{log.adminUsername || '—'}</strong></span>

View File

@ -115,18 +115,18 @@ const HandlerDashboard = ({ handlerUser, onLogout }) => {
) : (
<form onSubmit={handleSave} className="handler-edit-form">
<div className="form-group">
<label>Adresse</label>
<input type="text" value={formData.address}
<label htmlFor="profil-adresse">Adresse</label>
<input id="profil-adresse" type="text" value={formData.address}
onChange={e => setFormData({ ...formData, address: e.target.value })} required />
</div>
<div className="form-group">
<label>Mobilnummer</label>
<input type="tel" value={formData.phone}
<label htmlFor="profil-mobil">Mobilnummer</label>
<input id="profil-mobil" type="tel" value={formData.phone}
onChange={e => setFormData({ ...formData, phone: e.target.value })} required />
</div>
<div className="form-group">
<label>Festnetz (optional)</label>
<input type="tel" value={formData.landline}
<label htmlFor="profil-festnetz">Festnetz (optional)</label>
<input id="profil-festnetz" type="tel" value={formData.landline}
onChange={e => setFormData({ ...formData, landline: e.target.value })} />
</div>
<div className="handler-form-actions">

View File

@ -87,12 +87,12 @@ const HandlerLogin = ({ onLogin }) => {
{mode === 'login' ? (
<form onSubmit={handleLogin} className="handler-form">
<div className="form-group">
<label>E-Mail</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
<label htmlFor="login-email">E-Mail</label>
<input id="login-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
</div>
<div className="form-group">
<label>Passwort</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
<label htmlFor="login-password">Passwort</label>
<input id="login-password" type="password" value={password} onChange={e => setPassword(e.target.value)} required />
</div>
<button type="submit" className="btn-handler-primary" disabled={loading}>
{loading ? 'Anmelden...' : 'Anmelden'}
@ -104,20 +104,20 @@ const HandlerLogin = ({ onLogin }) => {
Der Admin hat Ihre E-Mail-Adresse hinterlegt. Geben Sie hier Ihre E-Mail, den Einladungs-Token und ein neues Passwort ein.
</p>
<div className="form-group">
<label>E-Mail</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
<label htmlFor="setpw-email">E-Mail</label>
<input id="setpw-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
</div>
<div className="form-group">
<label>Einladungs-Token</label>
<input type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
<label htmlFor="setpw-token">Einladungs-Token</label>
<input id="setpw-token" type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
</div>
<div className="form-group">
<label>Neues Passwort (min. 8 Zeichen)</label>
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} />
<label htmlFor="setpw-password">Neues Passwort (min. 8 Zeichen)</label>
<input id="setpw-password" type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} />
</div>
<div className="form-group">
<label>Passwort wiederholen</label>
<input type="password" value={newPassword2} onChange={e => setNewPassword2(e.target.value)} required />
<label htmlFor="setpw-repeat">Passwort wiederholen</label>
<input id="setpw-repeat" type="password" value={newPassword2} onChange={e => setNewPassword2(e.target.value)} required />
</div>
<button type="submit" className="btn-handler-primary" disabled={loading}>
{loading ? 'Wird gesetzt...' : 'Passwort setzen'}

View File

@ -20,8 +20,8 @@ const FilterPanel = ({ filters, onFilterChange, showAvailableFilter = true }) =>
</div>
)}
<div className="filter-group">
<label>Typ:</label>
<select
<label htmlFor="filter-typ">Typ:</label>
<select id="filter-typ"
value={filters.type || ''}
onChange={(e) => onFilterChange('type', e.target.value || null)}
className="filter-select"
@ -35,8 +35,8 @@ const FilterPanel = ({ filters, onFilterChange, showAvailableFilter = true }) =>
</select>
</div>
<div className="filter-group">
<label>Sortierung:</label>
<select
<label htmlFor="filter-sortierung">Sortierung:</label>
<select id="filter-sortierung"
value={filters.sortBy || 'name'}
onChange={(e) => onFilterChange('sortBy', e.target.value)}
className="filter-select"

View File

@ -13,6 +13,11 @@
in beiden Varianten nachgerechnet.
*/
:root {
/* Sagt dem Browser, dass die Seite beide Ansichten kann. Native Bedienelemente
Checkboxen, Auswahllisten, Bildlaufleisten passen sich dadurch an,
statt in der Nachtansicht hell aufzublitzen. */
color-scheme: light dark;
--font-display: Georgia, 'Times New Roman', serif;
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
@ -159,9 +164,6 @@ body {
-moz-osx-font-smoothing: grayscale;
background: var(--color-bg);
color: var(--color-text);
/* Damit auch vom Browser gestellte Bedienelemente (Bildlaufleisten,
Datumsauswahl, Autofill) der gewählten Ansicht folgen. */
color-scheme: light dark;
}
/* Formularfelder brauchen ausdrücklich Farben: ohne sie nimmt der Browser
@ -179,6 +181,13 @@ textarea::placeholder {
opacity: 1;
}
/* Checkbox, Radio und Schieberegler in der Markenfarbe statt im Browser-Blau. */
input[type="checkbox"],
input[type="radio"],
input[type="range"] {
accent-color: var(--color-primary);
}
h1, h2, h3 {
font-family: var(--font-display);
}

View File

@ -4,6 +4,9 @@
.admin-tabs {
display: inline-flex;
/* Vier Reiter passen auf schmalen Displays nicht in eine Zeile ohne
Umbruch lief die Seite bei 360 px horizontal ueber. */
flex-wrap: wrap;
gap: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
@ -14,6 +17,7 @@
}
.tab-button {
min-height: var(--touch-target);
padding: 0.5rem 1rem;
border: none;
background: transparent;
@ -122,7 +126,7 @@
.settings-section-count {
font-size: 0.8rem;
color: var(--color-text-muted);
background: var(--color-border);
background: var(--color-surface-alt);
border-radius: 999px;
padding: 0.1rem 0.5rem;
}
@ -164,7 +168,7 @@
font-size: 0.75rem;
font-weight: 700;
color: var(--color-text-muted);
background: var(--color-border);
background: var(--color-surface-alt);
border-radius: 50%;
flex-shrink: 0;
}
@ -346,3 +350,16 @@
opacity: 1;
}
}
@media (max-width: 480px) {
.admin-tabs {
display: flex;
width: 100%;
}
.tab-button {
flex: 1 1 45%;
padding: 0.5rem 0.6rem;
font-size: 0.9rem;
}
}

View File

@ -371,8 +371,8 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
</div>
<div className="settings-section-body">
<div className="settings-field">
<label className="settings-label">Name der App (wird in der Kopfzeile angezeigt)</label>
<input
<label className="settings-label" htmlFor="settings-appname">Name der App (wird in der Kopfzeile angezeigt)</label>
<input id="settings-appname"
type="text"
className="settings-input"
value={config.appName || ''}
@ -439,8 +439,8 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
{ key: 'ansprechpartner', label: 'Ansprechpartner und Koordination' }
].map(({ key, label }) => (
<div key={key} className="settings-field">
<label className="settings-label">{label}</label>
<textarea
<label className="settings-label" htmlFor={`settings-section-${key}`}>{label}</label>
<textarea id={`settings-section-${key}`}
value={getSectionContent(key)}
onChange={(e) => updateSection(key, e.target.value)}
className="settings-textarea settings-textarea-lg"

View File

@ -231,7 +231,7 @@
.badge-login-failed { background: #ffcdd2; color: #b71c1c; font-weight: 700; }
.badge-import { background: #e0f7fa; color: #00695c; }
.badge-export { background: #e8f5e9; color: #1b5e20; }
.badge-bulk-update { background: var(--color-surface-alt); color: #283593; }
.badge-bulk-update { background: #e8eaf6; color: #283593; }
.badge-bulk-delete { background: #fbe9e7; color: #bf360c; }
.badge-password { background: #fff8e1; color: #9c4e00; }
.badge-default { background: #f5f5f5; color: #616161; }
@ -280,14 +280,25 @@
.audit-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.audit-item-failed { border-left: 4px solid var(--color-danger); }
/* Als <button> ausgezeichnet, damit das Auf- und Zuklappen auch mit der
Tastatur erreichbar ist. Die Button-Grundstile werden hier zurueckgesetzt. */
.audit-item-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
flex-wrap: wrap;
width: 100%;
text-align: left;
font: inherit;
color: inherit;
background: var(--color-surface-alt);
border: none;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
}
.audit-item-header:disabled {
cursor: default;
}
.audit-time { margin-left: auto; color: var(--color-text-muted); font-size: 12px; white-space: nowrap; }
.expand-toggle { color: var(--color-text-muted); font-size: 11px; cursor: pointer; padding: 0 4px; }
@ -502,13 +513,6 @@
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.audit-item-header {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
}
.audit-badge {
padding: 6px 12px;

View File

@ -228,8 +228,13 @@ function AuditLogs() {
return (
<div key={log._id} className={`audit-item ${!log.success ? 'audit-item-failed' : ''}`}>
<div className="audit-item-header" onClick={() => hasDetail && toggleExpand(log._id)}
style={{ cursor: hasDetail ? 'pointer' : 'default' }}>
<button
type="button"
className="audit-item-header"
onClick={() => toggleExpand(log._id)}
disabled={!hasDetail}
aria-expanded={hasDetail ? isExpanded : undefined}
>
<span className={`audit-badge ${ACTION_BADGE[log.action] || 'badge-default'}`}>
{ACTION_ICONS[log.action]} {ACTION_LABELS[log.action] || log.action}
</span>
@ -244,7 +249,7 @@ function AuditLogs() {
{hasDetail && (
<span className="expand-toggle">{isExpanded ? '▲' : '▼'}</span>
)}
</div>
</button>
<div className="audit-item-body">
<span className="audit-admin">👤 <strong>{log.adminUsername || '—'}</strong></span>

View File

@ -115,18 +115,18 @@ const StoeberhundefuehrerDashboard = ({ stoeberhundefuehrerUser, onLogout }) =>
) : (
<form onSubmit={handleSave} className="stoeberhundefuehrer-edit-form">
<div className="form-group">
<label>Adresse</label>
<input type="text" value={formData.address}
<label htmlFor="profil-adresse">Adresse</label>
<input id="profil-adresse" type="text" value={formData.address}
onChange={e => setFormData({ ...formData, address: e.target.value })} required />
</div>
<div className="form-group">
<label>Mobilnummer</label>
<input type="tel" value={formData.phone}
<label htmlFor="profil-mobil">Mobilnummer</label>
<input id="profil-mobil" type="tel" value={formData.phone}
onChange={e => setFormData({ ...formData, phone: e.target.value })} required />
</div>
<div className="form-group">
<label>Festnetz (optional)</label>
<input type="tel" value={formData.landline}
<label htmlFor="profil-festnetz">Festnetz (optional)</label>
<input id="profil-festnetz" type="tel" value={formData.landline}
onChange={e => setFormData({ ...formData, landline: e.target.value })} />
</div>
<div className="stoeberhundefuehrer-form-actions">

View File

@ -87,12 +87,12 @@ const StoeberhundefuehrerLogin = ({ onLogin }) => {
{mode === 'login' ? (
<form onSubmit={handleLogin} className="stoeberhundefuehrer-form">
<div className="form-group">
<label>E-Mail</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
<label htmlFor="login-email">E-Mail</label>
<input id="login-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
</div>
<div className="form-group">
<label>Passwort</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
<label htmlFor="login-password">Passwort</label>
<input id="login-password" type="password" value={password} onChange={e => setPassword(e.target.value)} required />
</div>
<button type="submit" className="btn-stoeberhundefuehrer-primary" disabled={loading}>
{loading ? 'Anmelden...' : 'Anmelden'}
@ -104,20 +104,20 @@ const StoeberhundefuehrerLogin = ({ onLogin }) => {
Der Admin hat Ihre E-Mail-Adresse hinterlegt. Geben Sie hier Ihre E-Mail, den Einladungs-Token und ein neues Passwort ein.
</p>
<div className="form-group">
<label>E-Mail</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
<label htmlFor="setpw-email">E-Mail</label>
<input id="setpw-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
</div>
<div className="form-group">
<label>Einladungs-Token</label>
<input type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
<label htmlFor="setpw-token">Einladungs-Token</label>
<input id="setpw-token" type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
</div>
<div className="form-group">
<label>Neues Passwort (min. 8 Zeichen)</label>
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} />
<label htmlFor="setpw-password">Neues Passwort (min. 8 Zeichen)</label>
<input id="setpw-password" type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} />
</div>
<div className="form-group">
<label>Passwort wiederholen</label>
<input type="password" value={newPassword2} onChange={e => setNewPassword2(e.target.value)} required />
<label htmlFor="setpw-repeat">Passwort wiederholen</label>
<input id="setpw-repeat" type="password" value={newPassword2} onChange={e => setNewPassword2(e.target.value)} required />
</div>
<button type="submit" className="btn-stoeberhundefuehrer-primary" disabled={loading}>
{loading ? 'Wird gesetzt...' : 'Passwort setzen'}

View File

@ -20,8 +20,8 @@ const FilterPanel = ({ filters, onFilterChange, showAvailableFilter = true }) =>
</div>
)}
<div className="filter-group">
<label>Typ:</label>
<select
<label htmlFor="filter-typ">Typ:</label>
<select id="filter-typ"
value={filters.type || ''}
onChange={(e) => onFilterChange('type', e.target.value || null)}
className="filter-select"
@ -35,8 +35,8 @@ const FilterPanel = ({ filters, onFilterChange, showAvailableFilter = true }) =>
</select>
</div>
<div className="filter-group">
<label>Sortierung:</label>
<select
<label htmlFor="filter-sortierung">Sortierung:</label>
<select id="filter-sortierung"
value={filters.sortBy || 'name'}
onChange={(e) => onFilterChange('sortBy', e.target.value)}
className="filter-select"