polish(mobile): audit design DSFR multi-agents — tokens, a11y, états, finitions

- tokens centralisés (Tint, IconSize, LineHeight, LetterSpacing), zéro hex en dur
- accessibilité (rôles, labels, live regions, afficher/masquer mot de passe)
- états vides/erreur complétés (ex. saisie introuvable), cibles tactiles >=44px
- suppression du composant mort RepubliqueHeader

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Azmog
2026-06-19 15:29:40 +02:00
parent 7fd27d8dc2
commit 91ceca2be5
29 changed files with 433 additions and 224 deletions
+6 -6
View File
@@ -1,6 +1,6 @@
import { StyleSheet, Text, View } from 'react-native';
import { Colors } from '@/lib/colors';
import { FontSize, FontWeight, Spacing } from '@/lib/theme';
import { FontSize, FontWeight, LetterSpacing, LineHeight, Spacing } from '@/lib/theme';
interface AppBarProps {
/** Titre de la page, affiché sous le bandeau institutionnel. */
@@ -17,7 +17,7 @@ interface AppBarProps {
*/
export default function AppBar({ title, subtitle, right }: AppBarProps) {
return (
<View style={styles.wrapper}>
<View style={styles.wrapper} accessibilityRole="header">
<View style={styles.brandRow}>
<View style={styles.marianne}>
<Text style={styles.republique}>RÉPUBLIQUE</Text>
@@ -61,12 +61,12 @@ const styles = StyleSheet.create({
fontSize: FontSize.tiny,
fontWeight: FontWeight.bold,
color: Colors.black,
letterSpacing: 0.5,
lineHeight: 13,
letterSpacing: LetterSpacing.sm,
lineHeight: LineHeight.tight,
},
serviceBlock: { flex: 1 },
serviceName: { fontSize: FontSize.lg, fontWeight: FontWeight.heavy, color: Colors.primary },
serviceTagline: { fontSize: FontSize.tiny, color: Colors.gray[500], marginTop: 1 },
serviceTagline: { fontSize: FontSize.tiny, color: Colors.gray[500], marginTop: Spacing.xs / 4 },
right: { marginLeft: Spacing.sm },
titleRow: {
paddingHorizontal: Spacing.lg,
@@ -74,5 +74,5 @@ const styles = StyleSheet.create({
paddingTop: Spacing.xs,
},
title: { fontSize: FontSize.xxl, fontWeight: FontWeight.bold, color: Colors.black },
subtitle: { fontSize: FontSize.sm, color: Colors.gray[500], marginTop: 2 },
subtitle: { fontSize: FontSize.sm, color: Colors.gray[500], marginTop: Spacing.xs / 2 },
});
+7 -12
View File
@@ -1,6 +1,6 @@
import { StyleSheet, Text, View } from 'react-native';
import { Colors } from '@/lib/colors';
import { FontSize, FontWeight, Radius, Spacing } from '@/lib/theme';
import { FontSize, FontWeight, LetterSpacing, Radius, Spacing, Tint } from '@/lib/theme';
type Tone = 'neutral' | 'success' | 'error' | 'info' | 'warning' | 'primary';
@@ -14,7 +14,7 @@ interface BadgeProps {
/** Pastille d'état DSFR, taille homogène. */
export default function Badge({ label, tone = 'neutral', color, uppercase = false }: BadgeProps) {
const palette = color ? { bg: hexWithAlpha(color, 0.14), text: darken(color) } : TONES[tone];
const palette = color ? { bg: hexWithAlpha(color, 0.14), text: color } : TONES[tone];
return (
<View style={[styles.badge, { backgroundColor: palette.bg }]}>
<Text style={[styles.text, { color: palette.text }, uppercase && styles.uppercase]} numberOfLines={1}>
@@ -28,9 +28,9 @@ const TONES: Record<Tone, { bg: string; text: string }> = {
neutral: { bg: Colors.gray[100], text: Colors.gray[600] },
success: { bg: Colors.successBg, text: Colors.success },
error: { bg: Colors.errorBg, text: Colors.error },
info: { bg: '#E8EDFF', text: Colors.info },
warning: { bg: '#FFE9C7', text: Colors.warning },
primary: { bg: '#E3E3FD', text: Colors.primary },
info: { bg: Tint.info, text: Colors.info },
warning: { bg: Tint.warning, text: Colors.warning },
primary: { bg: Tint.primary, text: Colors.primary },
};
/** Ajoute un canal alpha (0–1) à une couleur hex #RRGGBB. */
@@ -39,18 +39,13 @@ function hexWithAlpha(hex: string, alpha: number): string {
return `${hex}${a}`;
}
/** Pour le texte sur fond teinté, on garde la couleur d'origine (lisible sur 14% d'alpha). */
function darken(hex: string): string {
return hex;
}
const styles = StyleSheet.create({
badge: {
alignSelf: 'flex-start',
paddingHorizontal: Spacing.sm,
paddingVertical: 3,
paddingVertical: Spacing.xs - 1,
borderRadius: Radius.sm,
},
text: { fontSize: FontSize.tiny, fontWeight: FontWeight.bold },
uppercase: { textTransform: 'uppercase', letterSpacing: 0.5 },
uppercase: { textTransform: 'uppercase', letterSpacing: LetterSpacing.sm },
});
+4 -3
View File
@@ -1,7 +1,7 @@
import { ActivityIndicator, Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Colors } from '@/lib/colors';
import { FontSize, FontWeight, MIN_TOUCH, Radius, Spacing } from '@/lib/theme';
import { FontSize, FontWeight, MIN_TOUCH, MIN_TOUCH_SM, Radius, Spacing } from '@/lib/theme';
type Variant = 'primary' | 'secondary' | 'danger' | 'tertiary';
type Size = 'md' | 'sm';
@@ -36,8 +36,9 @@ export default function Button({
}: ButtonProps) {
const isDisabled = disabled || loading;
const palette = VARIANTS[variant];
const minHeight = size === 'sm' ? 40 : MIN_TOUCH;
const minHeight = size === 'sm' ? MIN_TOUCH_SM : MIN_TOUCH;
const fontSize = size === 'sm' ? FontSize.sm : FontSize.md;
const iconSize = fontSize + Spacing.xs - 1;
return (
<Pressable
@@ -64,7 +65,7 @@ export default function Button({
<ActivityIndicator color={palette.text} size="small" />
) : (
<View style={styles.content}>
{icon && <Ionicons name={icon} size={fontSize + 3} color={palette.text} />}
{icon && <Ionicons name={icon} size={iconSize} color={palette.text} />}
<Text style={[styles.label, { color: palette.text, fontSize }]}>{title}</Text>
</View>
)}
+28 -4
View File
@@ -1,4 +1,11 @@
import { Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import {
Pressable,
StyleSheet,
View,
type AccessibilityRole,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Colors } from '@/lib/colors';
import { Radius, Shadow, Spacing } from '@/lib/theme';
@@ -9,18 +16,31 @@ interface CardProps {
padding?: number;
/** Légère ombre portée. */
elevated?: boolean;
/** Libellé d'accessibilité (lecteurs d'écran), utile sur une carte cliquable. */
accessibilityLabel?: string;
/** Rôle d'accessibilité (défaut « button » si cliquable). */
accessibilityRole?: AccessibilityRole;
style?: StyleProp<ViewStyle>;
}
/** Conteneur DSFR : fond blanc, bordure grise fine, coins peu arrondis. */
export default function Card({ children, onPress, padding = Spacing.lg, elevated = false, style }: CardProps) {
export default function Card({
children,
onPress,
padding = Spacing.lg,
elevated = false,
accessibilityLabel,
accessibilityRole,
style,
}: CardProps) {
const content = [styles.card, { padding }, elevated && Shadow.card, style];
if (onPress) {
return (
<Pressable
onPress={onPress}
accessibilityRole="button"
accessibilityRole={accessibilityRole ?? 'button'}
accessibilityLabel={accessibilityLabel}
style={({ pressed }) => [content, pressed && styles.pressed]}
>
{children}
@@ -28,7 +48,11 @@ export default function Card({ children, onPress, padding = Spacing.lg, elevated
);
}
return <View style={content}>{children}</View>;
return (
<View style={content} accessibilityRole={accessibilityRole} accessibilityLabel={accessibilityLabel}>
{children}
</View>
);
}
const styles = StyleSheet.create({
+6 -6
View File
@@ -1,7 +1,7 @@
import { StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Colors } from '@/lib/colors';
import { FontSize, FontWeight, Radius, Spacing } from '@/lib/theme';
import { FontSize, FontWeight, ICON_CIRCLE, IconSize, LineHeight, Radius, Spacing, Tint } from '@/lib/theme';
import Button from './Button';
interface EmptyStateProps {
@@ -17,7 +17,7 @@ export default function EmptyState({ icon = 'document-text-outline', title, mess
return (
<View style={styles.container}>
<View style={styles.iconCircle}>
<Ionicons name={icon} size={32} color={Colors.primary} />
<Ionicons name={icon} size={IconSize.xl} color={Colors.primary} />
</View>
<Text style={styles.title}>{title}</Text>
{message ? <Text style={styles.message}>{message}</Text> : null}
@@ -33,15 +33,15 @@ export default function EmptyState({ icon = 'document-text-outline', title, mess
const styles = StyleSheet.create({
container: { alignItems: 'center', justifyContent: 'center', paddingVertical: Spacing.xxxl, paddingHorizontal: Spacing.xl },
iconCircle: {
width: 72,
height: 72,
width: ICON_CIRCLE,
height: ICON_CIRCLE,
borderRadius: Radius.pill,
backgroundColor: '#E3E3FD',
backgroundColor: Tint.primary,
alignItems: 'center',
justifyContent: 'center',
marginBottom: Spacing.lg,
},
title: { fontSize: FontSize.lg, fontWeight: FontWeight.bold, color: Colors.black, textAlign: 'center' },
message: { fontSize: FontSize.sm, color: Colors.gray[500], textAlign: 'center', marginTop: Spacing.sm, lineHeight: 20 },
message: { fontSize: FontSize.sm, color: Colors.gray[500], textAlign: 'center', marginTop: Spacing.sm, lineHeight: LineHeight.normal },
action: { marginTop: Spacing.xl },
});
+1 -1
View File
@@ -5,7 +5,7 @@ import { FontSize, Spacing } from '@/lib/theme';
/** Indicateur de chargement centré et homogène, avec libellé optionnel. */
export default function Loader({ label }: { label?: string }) {
return (
<View style={styles.container}>
<View style={styles.container} accessibilityRole="progressbar" accessibilityLabel={label ?? 'Chargement en cours'}>
<ActivityIndicator size="large" color={Colors.primary} />
{label ? <Text style={styles.label}>{label}</Text> : null}
</View>
+7 -3
View File
@@ -1,10 +1,14 @@
import { StyleSheet, Text } from 'react-native';
import { Colors } from '@/lib/colors';
import { FontSize, FontWeight, Spacing } from '@/lib/theme';
import { FontSize, FontWeight, LetterSpacing, Spacing } from '@/lib/theme';
/** Intitulé de section (petites capitales DSFR) au-dessus d'une liste/groupe. */
export default function SectionTitle({ children }: { children: string }) {
return <Text style={styles.title}>{children}</Text>;
return (
<Text style={styles.title} accessibilityRole="header">
{children}
</Text>
);
}
const styles = StyleSheet.create({
@@ -13,7 +17,7 @@ const styles = StyleSheet.create({
fontWeight: FontWeight.bold,
color: Colors.gray[500],
textTransform: 'uppercase',
letterSpacing: 1,
letterSpacing: LetterSpacing.md,
marginBottom: Spacing.sm,
},
});
+59 -26
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import {
Pressable,
StyleSheet,
Text,
TextInput,
@@ -11,7 +12,7 @@ import {
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Colors } from '@/lib/colors';
import { FontSize, FontWeight, Radius, Spacing } from '@/lib/theme';
import { FontSize, FontWeight, HIT_SLOP, IconSize, MIN_TOUCH, Radius, Spacing } from '@/lib/theme';
interface TextFieldProps {
label: string;
@@ -53,7 +54,9 @@ export default function TextField({
style,
}: TextFieldProps) {
const [focused, setFocused] = useState(false);
const [revealed, setRevealed] = useState(false);
const borderColor = error ? Colors.error : focused ? Colors.primary : Colors.gray[600];
const isSecure = secureTextEntry && !revealed;
return (
<View style={[styles.container, style]}>
@@ -61,31 +64,53 @@ export default function TextField({
{label}
{required && <Text style={styles.required}> *</Text>}
</Text>
<TextInput
value={value}
onChangeText={onChangeText}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
placeholder={placeholder}
placeholderTextColor={Colors.gray[400]}
secureTextEntry={secureTextEntry}
keyboardType={keyboardType}
autoCapitalize={autoCapitalize}
autoComplete={autoComplete}
multiline={multiline}
numberOfLines={numberOfLines}
editable={editable}
style={[
styles.input,
{ borderBottomColor: borderColor },
multiline && styles.multiline,
!editable && styles.readonly,
]}
/>
<View style={styles.inputRow}>
<TextInput
value={value}
onChangeText={onChangeText}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
placeholder={placeholder}
placeholderTextColor={Colors.gray[400]}
secureTextEntry={isSecure}
keyboardType={keyboardType}
autoCapitalize={autoCapitalize}
autoComplete={autoComplete}
multiline={multiline}
numberOfLines={numberOfLines}
editable={editable}
accessibilityLabel={label}
accessibilityState={{ disabled: !editable }}
style={[
styles.input,
{ borderBottomColor: borderColor },
secureTextEntry && styles.inputWithIcon,
multiline && styles.multiline,
!editable && styles.readonly,
]}
/>
{secureTextEntry ? (
<Pressable
onPress={() => setRevealed((r) => !r)}
hitSlop={HIT_SLOP}
style={styles.toggle}
accessibilityRole="button"
accessibilityLabel={revealed ? 'Masquer le mot de passe' : 'Afficher le mot de passe'}
>
<Ionicons
name={revealed ? 'eye-off-outline' : 'eye-outline'}
size={IconSize.lg}
color={Colors.gray[500]}
/>
</Pressable>
) : null}
</View>
{error ? (
<View style={styles.messageRow}>
<Ionicons name="alert-circle" size={14} color={Colors.error} />
<Text style={styles.errorText}>{error}</Text>
<Ionicons name="alert-circle" size={IconSize.sm} color={Colors.error} />
<Text style={styles.errorText} accessibilityLiveRegion="polite">
{error}
</Text>
</View>
) : helper ? (
<Text style={styles.helperText}>{helper}</Text>
@@ -103,6 +128,7 @@ const styles = StyleSheet.create({
marginBottom: Spacing.xs,
},
required: { color: Colors.error },
inputRow: { position: 'relative', justifyContent: 'center' },
input: {
backgroundColor: Colors.gray[50],
borderTopLeftRadius: Radius.sm,
@@ -112,9 +138,16 @@ const styles = StyleSheet.create({
paddingVertical: Spacing.md,
fontSize: FontSize.body,
color: Colors.black,
minHeight: 44,
minHeight: MIN_TOUCH,
},
multiline: { minHeight: 110, textAlignVertical: 'top' },
inputWithIcon: { paddingRight: Spacing.xxl + Spacing.md },
toggle: {
position: 'absolute',
right: Spacing.md,
height: MIN_TOUCH,
justifyContent: 'center',
},
multiline: { minHeight: MIN_TOUCH * 2.5, textAlignVertical: 'top' },
readonly: { color: Colors.gray[500], backgroundColor: Colors.gray[100] },
messageRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.xs, marginTop: Spacing.xs },
errorText: { fontSize: FontSize.xs, color: Colors.error },