feat(settings): Akzentfarbe zusätzlich als Hex-Code eingebbar

- normalizeHexColor() (apps/web/src/lib/color.ts) normalisiert Hex-Eingaben (fuehrendes # optional, 3-stellige Kurzform, Gross-/Kleinschreibung, Leerraum)
- Kontoformular: Hex-Textfeld neben dem Farbwaehler, beide bidirektional synchron, ersetzt den reinen Anzeige-Span
- Ungueltiger Text: aria-invalid + roter Rand + Fehlertext, "Farbe speichern" gesperrt; Zuruecksetzen setzt beides auf #ffed00
- i18n: settings.account.accentColorHex/accentColorHexInvalid in de.json und en.json
- Unit-Tests: color.test.ts (9 Faelle), account-settings-form.test.tsx (7 Faelle)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 12:14:43 +02:00
parent f6eda20fcb
commit 795c6a492c
6 changed files with 341 additions and 5 deletions
@@ -0,0 +1,221 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AccountSettingsForm } from './account-settings-form';
/**
* account-settings-form.test — Akzentfarbe Hex-Eingabe (quick-260917-gsh).
* next-intl-Mock de.json-gestuetzt (Muster: desktop-app-settings.test.tsx),
* `@/lib/auth-actions` per vi.hoisted + vi.mock ersetzt.
*/
vi.mock('next-intl', async () => {
const messages = (await import('@/messages/de.json')).default as Record<string, unknown>;
const lookup = (path: string): string | undefined =>
path.split('.').reduce<unknown>((o, k) => (o && typeof o === 'object' ? (o as any)[k] : undefined), messages) as
| string
| undefined;
return {
useTranslations:
(ns?: string) =>
(key: string, values?: Record<string, unknown>) => {
const raw = lookup(ns ? `${ns}.${key}` : key) ?? key;
return values ? raw.replace(/\{(\w+)\}/g, (_: string, n: string) => String(values[n] ?? '')) : raw;
},
useLocale: () => 'de',
};
});
const { fetchCurrentUser, updateAccentColorAction, changePasswordAction, uploadAvatarAction, deleteAvatarAction } =
vi.hoisted(() => ({
fetchCurrentUser: vi.fn(),
updateAccentColorAction: vi.fn(),
changePasswordAction: vi.fn(),
uploadAvatarAction: vi.fn(),
deleteAvatarAction: vi.fn(),
}));
vi.mock('@/lib/auth-actions', () => ({
fetchCurrentUser,
updateAccentColorAction,
changePasswordAction,
uploadAvatarAction,
deleteAvatarAction,
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('AccountSettingsForm — Akzentfarbe Hex-Eingabe', () => {
it('prefills the hex textfield and the color picker from the loaded user', async () => {
fetchCurrentUser.mockResolvedValue({
id: 'u1',
username: 'max',
displayName: 'Max',
role: 'USER',
tenantId: 't1',
isLocalUser: true,
hasAvatar: false,
accentColor: '#123456',
});
render(<AccountSettingsForm />);
const hexInput = (await screen.findByRole('textbox', {
name: 'Hex-Code',
})) as HTMLInputElement;
expect(hexInput.value).toBe('#123456');
const colorPicker = document.querySelector('input[type="color"]') as HTMLInputElement;
expect(colorPicker.value).toBe('#123456');
});
it('accepts a valid hex value typed without # and syncs the color picker', async () => {
fetchCurrentUser.mockResolvedValue({
id: 'u1',
username: 'max',
displayName: 'Max',
role: 'USER',
tenantId: 't1',
isLocalUser: true,
hasAvatar: false,
accentColor: '#123456',
});
render(<AccountSettingsForm />);
const hexInput = (await screen.findByRole('textbox', {
name: 'Hex-Code',
})) as HTMLInputElement;
const colorPicker = document.querySelector('input[type="color"]') as HTMLInputElement;
fireEvent.change(hexInput, { target: { value: 'FFED00' } });
expect(colorPicker.value).toBe('#ffed00');
expect(hexInput).toHaveAttribute('aria-invalid', 'false');
expect(screen.getByText('Farbe speichern')).not.toBeDisabled();
});
it('marks an invalid hex value, blocks save, and keeps the last valid color picker value', async () => {
fetchCurrentUser.mockResolvedValue({
id: 'u1',
username: 'max',
displayName: 'Max',
role: 'USER',
tenantId: 't1',
isLocalUser: true,
hasAvatar: false,
accentColor: '#123456',
});
render(<AccountSettingsForm />);
const hexInput = (await screen.findByRole('textbox', {
name: 'Hex-Code',
})) as HTMLInputElement;
const colorPicker = document.querySelector('input[type="color"]') as HTMLInputElement;
fireEvent.change(hexInput, { target: { value: '#ggg' } });
expect(hexInput).toHaveAttribute('aria-invalid', 'true');
expect(hexInput.className).toContain('border-destructive');
expect(
screen.getByText('Ungültiger Farbwert. Bitte geben Sie sechs Hexadezimalzeichen ein, z. B. #ffed00.'),
).toBeInTheDocument();
expect(screen.getByText('Farbe speichern')).toBeDisabled();
expect(colorPicker.value).toBe('#123456');
});
it('syncs the hex textfield when the color picker changes', async () => {
fetchCurrentUser.mockResolvedValue({
id: 'u1',
username: 'max',
displayName: 'Max',
role: 'USER',
tenantId: 't1',
isLocalUser: true,
hasAvatar: false,
accentColor: '#123456',
});
render(<AccountSettingsForm />);
await screen.findByRole('textbox', { name: 'Hex-Code' });
const hexInput = screen.getByRole('textbox', { name: 'Hex-Code' }) as HTMLInputElement;
const colorPicker = document.querySelector('input[type="color"]') as HTMLInputElement;
fireEvent.change(colorPicker, { target: { value: '#00ff00' } });
expect(hexInput.value).toBe('#00ff00');
});
it('normalizes the hex textfield to canonical form on blur', async () => {
fetchCurrentUser.mockResolvedValue({
id: 'u1',
username: 'max',
displayName: 'Max',
role: 'USER',
tenantId: 't1',
isLocalUser: true,
hasAvatar: false,
accentColor: '#123456',
});
render(<AccountSettingsForm />);
const hexInput = (await screen.findByRole('textbox', {
name: 'Hex-Code',
})) as HTMLInputElement;
fireEvent.change(hexInput, { target: { value: '#fe0' } });
fireEvent.blur(hexInput);
expect(hexInput.value).toBe('#ffee00');
});
it('resets both the hex textfield and the color picker on "Zurücksetzen"', async () => {
fetchCurrentUser.mockResolvedValue({
id: 'u1',
username: 'max',
displayName: 'Max',
role: 'USER',
tenantId: 't1',
isLocalUser: true,
hasAvatar: false,
accentColor: '#123456',
});
updateAccentColorAction.mockResolvedValue({ success: true });
render(<AccountSettingsForm />);
const hexInput = (await screen.findByRole('textbox', {
name: 'Hex-Code',
})) as HTMLInputElement;
const colorPicker = document.querySelector('input[type="color"]') as HTMLInputElement;
fireEvent.click(screen.getByText('Zurücksetzen'));
await waitFor(() => {
expect(hexInput.value).toBe('#ffed00');
});
expect(colorPicker.value).toBe('#ffed00');
expect(updateAccentColorAction).toHaveBeenCalledWith(null);
});
it('saves the normalized color and shows the success message', async () => {
fetchCurrentUser.mockResolvedValue({
id: 'u1',
username: 'max',
displayName: 'Max',
role: 'USER',
tenantId: 't1',
isLocalUser: true,
hasAvatar: false,
accentColor: '#ffed00',
});
updateAccentColorAction.mockResolvedValue({ success: true });
render(<AccountSettingsForm />);
await screen.findByRole('textbox', { name: 'Hex-Code' });
fireEvent.click(screen.getByText('Farbe speichern'));
expect(await screen.findByText('Akzentfarbe erfolgreich gespeichert.')).toBeInTheDocument();
expect(updateAccentColorAction).toHaveBeenCalledWith('#ffed00');
});
});
@@ -11,6 +11,7 @@ import {
} from '@/lib/auth-actions';
import { useAuthStore } from '@/lib/stores/auth-store';
import { BRAND_YELLOW } from '@/components/brand/brand';
import { normalizeHexColor } from '@/lib/color';
const DEFAULT_ACCENT = BRAND_YELLOW;
@@ -38,6 +39,8 @@ export function AccountSettingsForm() {
// Accent color state
const [accentColor, setAccentColor] = useState<string>(DEFAULT_ACCENT);
const [hexInput, setHexInput] = useState<string>(DEFAULT_ACCENT);
const isHexValid = normalizeHexColor(hexInput) !== null;
const [accentSuccess, setAccentSuccess] = useState(false);
const [accentError, setAccentError] = useState<string | null>(null);
const [isAccentPending, startAccentTransition] = useTransition();
@@ -48,7 +51,9 @@ export function AccountSettingsForm() {
setIsLocalUser(u.isLocalUser ?? false);
setUsername(u.displayName ?? u.username ?? '');
setHasAvatar(!!u.hasAvatar);
setAccentColor(u.accentColor ?? DEFAULT_ACCENT);
const c = u.accentColor ?? DEFAULT_ACCENT;
setAccentColor(c);
setHexInput(c);
}
});
}, []);
@@ -113,6 +118,7 @@ export function AccountSettingsForm() {
}
function handleSaveAccentColor() {
if (!isHexValid) return;
setAccentSuccess(false);
setAccentError(null);
startAccentTransition(async () => {
@@ -128,6 +134,7 @@ export function AccountSettingsForm() {
function handleResetAccentColor() {
setAccentColor(DEFAULT_ACCENT);
setHexInput(DEFAULT_ACCENT);
setAccentSuccess(false);
setAccentError(null);
startAccentTransition(async () => {
@@ -212,14 +219,41 @@ export function AccountSettingsForm() {
{t('account.accentColorLabel')}
</h2>
<p className="text-xs text-muted-foreground mb-4">{t('account.accentColorHelp')}</p>
<div className="flex items-center gap-3 mb-4">
<div className="flex items-center gap-3 mb-1">
<input
type="color"
value={accentColor}
onChange={(e) => setAccentColor(e.target.value)}
onChange={(e) => {
setAccentColor(e.target.value);
setHexInput(e.target.value);
}}
className="h-10 w-16 cursor-pointer rounded-md border border-input p-1"
/>
<span className="font-mono text-sm text-muted-foreground">{accentColor}</span>
<input
id="accentColorHex"
type="text"
inputMode="text"
autoComplete="off"
spellCheck={false}
maxLength={7}
placeholder={DEFAULT_ACCENT}
value={hexInput}
aria-label={t('account.accentColorHex')}
aria-invalid={!isHexValid}
onChange={(e) => {
const raw = e.target.value;
setHexInput(raw);
const n = normalizeHexColor(raw);
if (n) setAccentColor(n);
}}
onBlur={() => {
const n = normalizeHexColor(hexInput);
if (n) setHexInput(n);
}}
className={`h-10 w-28 rounded-md border bg-background px-3 py-2 font-mono text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
isHexValid ? 'border-input' : 'border-destructive'
}`}
/>
<button
type="button"
onClick={handleResetAccentColor}
@@ -229,6 +263,9 @@ export function AccountSettingsForm() {
{t('account.accentColorReset')}
</button>
</div>
{!isHexValid && (
<p className="text-xs text-destructive mb-3">{t('account.accentColorHexInvalid')}</p>
)}
{accentSuccess && (
<div className="rounded-md bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 px-4 py-3 text-sm text-green-800 dark:text-green-200 mb-3">
{t('account.accentColorSuccess')}
@@ -242,7 +279,7 @@ export function AccountSettingsForm() {
<button
type="button"
onClick={handleSaveAccentColor}
disabled={isAccentPending}
disabled={isAccentPending || !isHexValid}
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
>
{isAccentPending ? '...' : t('account.accentColorSave')}