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
@@ -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')}