diff --git a/apps/web/src/components/settings/appearance/AppearanceSettingsView.tsx b/apps/web/src/components/settings/appearance/AppearanceSettingsView.tsx
index b4aa87bc7..04094cc5f 100644
--- a/apps/web/src/components/settings/appearance/AppearanceSettingsView.tsx
+++ b/apps/web/src/components/settings/appearance/AppearanceSettingsView.tsx
@@ -1,8 +1,7 @@
-import React, { useState } from 'react';
+import React, { useEffect, useCallback } from 'react';
import { Card } from '../../ui/card';
import { Button } from '../../ui/button';
import { useTheme } from '../../../components/theme/ThemeProvider';
-import { ThemeVariant } from '../../../types';
import {
Moon,
Sun,
@@ -15,28 +14,79 @@ import {
} from 'lucide-react';
import { useToast } from '../../../components/feedback/ToastProvider';
import { Switch } from '../../ui/switch';
+import { useAuth } from '@/features/auth/hooks/useAuth';
+import { userService } from '@/services/userService';
+
+const ACCENT_PRESETS = [
+ { id: 'indigo', hue: 220, hex: '#7c9dd6' },
+ { id: 'sage', hue: 120, hex: '#7a9e6c' },
+ { id: 'vermillion', hue: 15, hex: '#d4634a' },
+ { id: 'gold', hue: 45, hex: '#c9a84c' },
+ { id: 'sakura', hue: 340, hex: '#e0a0b8' },
+];
export const AppearanceSettingsView: React.FC = () => {
- const { theme, setTheme } = useTheme();
+ const {
+ theme,
+ setTheme,
+ contrast,
+ setContrast,
+ density,
+ setDensity,
+ accentHue,
+ setAccentHue,
+ fontSize,
+ setFontSize,
+ } = useTheme();
const { addToast } = useToast();
- const [density, setDensity] = useState<'comfortable' | 'compact' | 'cozy'>(
- 'comfortable',
- );
- const [fontSize, setFontSize] = useState(16);
- const [accentColor, setAccentColor] = useState('cyan');
- const [showSidebar, setShowSidebar] = useState(true);
+ const { isAuthenticated } = useAuth();
+ const [showSidebar, setShowSidebar] = React.useState(true);
- const accents = [
- { id: 'indigo', hex: '#7c9dd6' },
- { id: 'sage', hex: '#7a9e6c' },
- { id: 'vermillion', hex: '#d4634a' },
- { id: 'gold', hex: '#c9a84c' },
- { id: 'sakura', hex: '#e0a0b8' },
- ];
+ const loadPreferences = useCallback(async () => {
+ if (!isAuthenticated) return;
+ try {
+ const prefs = await userService.getPreferences();
+ const themeVal = prefs.theme as 'dark' | 'light' | 'system';
+ if (themeVal && ['dark', 'light', 'system'].includes(themeVal)) {
+ setTheme(themeVal);
+ }
+ setContrast(prefs.contrast === 'high' ? 'high' : 'normal');
+ const densityVal = prefs.density as 'comfortable' | 'compact' | 'cozy';
+ if (densityVal && ['comfortable', 'compact', 'cozy'].includes(densityVal)) {
+ setDensity(densityVal);
+ }
+ setAccentHue(prefs.accentHue ?? 220);
+ setFontSize(Math.min(20, Math.max(14, prefs.fontSize ?? 16)));
+ } catch {
+ /* ignore, use local state */
+ }
+ }, [isAuthenticated, setTheme, setContrast, setDensity, setAccentHue, setFontSize]);
+
+ useEffect(() => {
+ loadPreferences();
+ }, [loadPreferences]);
+
+ const handleSave = async () => {
+ if (isAuthenticated) {
+ try {
+ await userService.updatePreferences({
+ theme,
+ contrast,
+ density,
+ accentHue,
+ fontSize,
+ });
+ addToast('Appearance settings saved', 'success');
+ } catch {
+ addToast('Failed to save preferences', 'error');
+ }
+ } else {
+ addToast('Appearance settings saved', 'success');
+ }
+ };
return (
- {/* Header */}
@@ -46,53 +96,36 @@ export const AppearanceSettingsView: React.FC = () => {
Customize your visual experience.
-
- {/* Theme Selection */}
+ {/* Theme + High Contrast */}
Theme
-
+
{[
- {
- id: ThemeVariant.NEON,
- label: 'Neon Dark',
- icon:
,
- },
- {
- id: ThemeVariant.LIGHT,
- label: 'Light Mode',
- icon:
,
- },
- {
- id: ThemeVariant.GAMING,
- label: 'High Contrast',
- icon:
,
- },
+ { id: 'dark' as const, label: 'Neon Dark', icon:
},
+ { id: 'light' as const, label: 'Light Mode', icon:
},
+ { id: 'system' as const, label: 'System', icon:
},
].map((opt) => (
{
- const map: Record
= {
- [ThemeVariant.NEON]: 'dark',
- [ThemeVariant.LIGHT]: 'light',
- [ThemeVariant.GAMING]: 'dark',
- [ThemeVariant.NATURE]: 'light',
- [ThemeVariant.TERMINAL]: 'dark', // Gaming treated as dark for now
- };
- setTheme(map[opt.id]);
+ role="button"
+ tabIndex={0}
+ onClick={() => setTheme(opt.id)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ setTheme(opt.id);
+ }
}}
- className={`
- cursor-pointer p-6 rounded-xl border-2 transition-all flex flex-col items-center gap-4 relative
- ${theme === opt.id ? 'border-primary bg-primary/5' : 'border-border bg-card hover:border-border'}
- `}
+ className={`cursor-pointer p-6 rounded-xl border-2 transition-all flex flex-col items-center gap-4 relative ${
+ theme === opt.id ? 'border-primary bg-primary/5' : 'border-border bg-card hover:border-border'
+ }`}
>
{
))}
+
+
+
High Contrast
+
+ WCAG AA compliant colors for better visibility
+
+
+
setContrast(checked ? 'high' : 'normal')}
+ />
+
- {/* Display Density */}
+ {/* Density + Typography */}
@@ -122,35 +167,34 @@ export const AppearanceSettingsView: React.FC = () => {
{[
- {
- id: 'comfortable',
- label: 'Comfortable',
- desc: 'More whitespace for readability',
- },
- { id: 'cozy', label: 'Cozy', desc: 'Balanced spacing' },
- { id: 'compact', label: 'Compact', desc: 'Maximum data density' },
+ { id: 'comfortable' as const, label: 'Comfortable', desc: 'More whitespace for readability' },
+ { id: 'cozy' as const, label: 'Cozy', desc: 'Balanced spacing' },
+ { id: 'compact' as const, label: 'Compact', desc: 'Maximum data density' },
].map((opt) => (
setDensity(opt.id as 'comfortable' | 'compact' | 'cozy')}
- className={`
- flex items-center gap-4 p-4 rounded-lg border cursor-pointer transition-all
- ${density === opt.id ? 'bg-primary/10 border-primary' : 'bg-card border-border hover:bg-muted/50'}
- `}
+ role="button"
+ tabIndex={0}
+ onClick={() => setDensity(opt.id)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ setDensity(opt.id);
+ }
+ }}
+ className={`flex items-center gap-4 p-4 rounded-lg border cursor-pointer transition-all ${
+ density === opt.id ? 'bg-primary/10 border-primary' : 'bg-card border-border hover:bg-muted/50'
+ }`}
>
{density === opt.id && (
-
+
)}
-
- {opt.label}
-
+
{opt.label}
{opt.desc}
@@ -170,11 +214,12 @@ export const AppearanceSettingsView: React.FC = () => {
setFontSize(Number(e.target.value))}
className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:bg-warning [&::-webkit-slider-thumb]:rounded-full"
+ aria-label="Font size"
/>
{
- {/* Colors & Layout */}
+ {/* Accent + Layout */}
Accent Color
- {accents.map((col) => (
+ {ACCENT_PRESETS.map((col) => (
setAccentColor(col.id)}
- className={`w-10 h-10 rounded-full cursor-pointer flex items-center justify-center transition-opacity hover:opacity-80 ring-2 ring-offset-2 ring-offset-background ${accentColor === col.id ? 'ring-white' : 'ring-transparent'}`}
+ role="button"
+ tabIndex={0}
+ onClick={() => setAccentHue(col.hue)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ setAccentHue(col.hue);
+ }
+ }}
+ className={`w-10 h-10 rounded-full cursor-pointer flex items-center justify-center transition-opacity hover:opacity-80 ring-2 ring-offset-2 ring-offset-background ${
+ accentHue === col.hue ? 'ring-white' : 'ring-transparent'
+ }`}
style={{ backgroundColor: col.hex }}
+ aria-label={`Accent color ${col.id}`}
>
- {accentColor === col.id && (
-
- )}
+ {accentHue === col.hue && }
))}
@@ -225,7 +279,7 @@ export const AppearanceSettingsView: React.FC = () => {
setShowSidebar(!showSidebar)}
+ onCheckedChange={() => setShowSidebar(!showSidebar)}
/>
diff --git a/apps/web/src/mocks/handlers-misc.ts b/apps/web/src/mocks/handlers-misc.ts
index 09506fb3a..e4a0fe426 100644
--- a/apps/web/src/mocks/handlers-misc.ts
+++ b/apps/web/src/mocks/handlers-misc.ts
@@ -339,6 +339,47 @@ export const handlersMisc = [
return HttpResponse.json({ success: true });
}),
+ // v0.801: User appearance preferences (contrast, density, accentHue, fontSize)
+ http.get('*/api/v1/users/me/preferences', () => {
+ return HttpResponse.json({
+ success: true,
+ data: {
+ user_id: 'user-1',
+ theme: 'dark',
+ language: 'en',
+ timezone: 'UTC',
+ notifications: {},
+ privacy: {},
+ audio: {},
+ contrast: 'normal',
+ density: 'comfortable',
+ accentHue: 220,
+ fontSize: 16,
+ updated_at: new Date().toISOString(),
+ },
+ });
+ }),
+ http.put('*/api/v1/users/me/preferences', async ({ request }) => {
+ const body = (await request.json()) as Record
;
+ return HttpResponse.json({
+ success: true,
+ data: {
+ user_id: 'user-1',
+ theme: body.theme ?? 'dark',
+ language: body.language ?? 'en',
+ timezone: body.timezone ?? 'UTC',
+ notifications: body.notifications ?? {},
+ privacy: body.privacy ?? {},
+ audio: body.audio ?? {},
+ contrast: body.contrast ?? 'normal',
+ density: body.density ?? 'comfortable',
+ accentHue: body.accentHue ?? 220,
+ fontSize: body.fontSize ?? 16,
+ updated_at: new Date().toISOString(),
+ },
+ });
+ }),
+
http.get('*/api/v1/users/by-username/:username', ({ params }) => {
if (params.username === 'notfound') {
return HttpResponse.json({ message: 'User not found' }, { status: 404 });
diff --git a/apps/web/src/services/userService.ts b/apps/web/src/services/userService.ts
index 9fc85a7c1..22ff237a8 100644
--- a/apps/web/src/services/userService.ts
+++ b/apps/web/src/services/userService.ts
@@ -112,6 +112,44 @@ export const userService = {
return response.data;
},
+ /**
+ * Récupère les préférences de l'utilisateur connecté (v0.801: appearance)
+ */
+ getPreferences: async () => {
+ const response = await apiClient.get<{
+ user_id: string;
+ theme: string;
+ language: string;
+ timezone: string;
+ contrast: string;
+ density: string;
+ accentHue: number;
+ fontSize: number;
+ }>('/users/me/preferences');
+ return response.data;
+ },
+
+ /**
+ * Met à jour les préférences de l'utilisateur connecté (v0.801: appearance)
+ */
+ updatePreferences: async (data: {
+ theme?: string;
+ contrast?: string;
+ density?: string;
+ accentHue?: number;
+ fontSize?: number;
+ }) => {
+ const response = await apiClient.put<{
+ user_id: string;
+ theme: string;
+ contrast: string;
+ density: string;
+ accentHue: number;
+ fontSize: number;
+ }>('/users/me/preferences', data);
+ return response.data;
+ },
+
/**
* Exporte les données de l'utilisateur (GDPR)
*/