Add dark mode and accessibility features for improved user experience

Integrate a dark mode, an accessibility panel with options for dyslexia, high contrast, and text scaling, and enhance keyboard navigation. Update documentation to reflect these changes.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 923ae0e3-a363-4db8-b04a-e8baca2a1330
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: bbd001b6-1b5f-4425-9310-55a9081dabf8
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8af7d2ec-2cc3-4ece-8af3-9f071488d072/923ae0e3-a363-4db8-b04a-e8baca2a1330/vOeFCU4
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
pironantoine
2026-04-04 11:45:46 +00:00
parent 176b49d796
commit 78eb58844e
8 changed files with 365 additions and 51 deletions
+3 -3
View File
@@ -17,16 +17,16 @@ title = "Tutoriel Gitea — inchangé"
id = "wdBpdE1lSme8lM2xYd3oJ"
uri = "file://docs/DAT.md"
type = "text"
title = "DAT v1.2 — Architecture Technique"
title = "DAT v1.3"
[[outputs]]
id = "NXFvDFOIzX862xNq15Mak"
uri = "file://docs/DEX.md"
type = "text"
title = "DEX v1.2 — Exploitation"
title = "DEX v1.3"
[[outputs]]
id = "kJNXgVnYp_LQmPcWr6Osb"
uri = "file://docs/WIKI.md"
type = "text"
title = "Wiki v1.2 — La Voix du Peuple"
title = "Wiki v1.3"
+19 -7
View File
@@ -7,6 +7,8 @@ import Home from "@/pages/home";
import About from "@/pages/about";
import Transparence from "@/pages/transparence";
import Flyer from "@/pages/flyer";
import { AccessibilityProvider } from "@/hooks/use-accessibility";
import { AccessibilityPanel } from "@/components/accessibility-panel";
const queryClient = new QueryClient({
defaultOptions: {
@@ -24,13 +26,14 @@ function Navbar() {
<Link href="/" className="flex items-center gap-2 mr-6 font-serif text-xl font-bold tracking-tight text-primary" data-testid="nav-home-link">
La Voix du Peuple
</Link>
<nav className="flex items-center gap-6 text-sm font-medium">
<nav className="flex items-center gap-6 text-sm font-medium" aria-label="Navigation principale">
<Link href="/" className="transition-colors hover:text-foreground/80 text-foreground/60" data-testid="nav-manifesto-link">Manifeste</Link>
<Link href="/about" className="transition-colors hover:text-foreground/80 text-foreground/60" data-testid="nav-about-link">À propos</Link>
<Link href="/transparence" className="transition-colors hover:text-foreground/80 text-foreground/60" data-testid="nav-transparence-link">Fonctionnement</Link>
<Link href="/flyer" className="transition-colors hover:text-foreground/80 text-foreground/60" data-testid="nav-flyer-link">Flyer QR</Link>
</nav>
<div className="ml-auto flex items-center">
<div className="ml-auto flex items-center gap-2">
<AccessibilityPanel />
<span
title="République française"
aria-label="Drapeau français"
@@ -50,8 +53,15 @@ function Navbar() {
function Router() {
return (
<div className="min-h-screen flex flex-col font-sans">
{/* Lien d'évitement pour lecteurs d'écran */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[100] focus:bg-primary focus:text-primary-foreground focus:px-4 focus:py-2 focus:rounded-sm focus:text-sm focus:font-semibold"
>
Aller au contenu principal
</a>
<Navbar />
<main className="flex-1 flex flex-col">
<main id="main-content" className="flex-1 flex flex-col" tabIndex={-1}>
<Switch>
<Route path="/" component={Home} />
<Route path="/about" component={About} />
@@ -68,10 +78,12 @@ function App() {
return (
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<WouterRouter base={import.meta.env.BASE_URL.replace(/\/$/, "")}>
<Router />
</WouterRouter>
<Toaster />
<AccessibilityProvider>
<WouterRouter base={import.meta.env.BASE_URL.replace(/\/$/, "")}>
<Router />
</WouterRouter>
<Toaster />
</AccessibilityProvider>
</TooltipProvider>
</QueryClientProvider>
);
@@ -0,0 +1,105 @@
import React from "react";
import { Accessibility, Moon, Sun, Type, Contrast, ZoomIn } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useAccessibility } from "@/hooks/use-accessibility";
interface ToggleRowProps {
icon: React.ReactNode;
label: string;
description: string;
active: boolean;
onToggle: () => void;
}
function ToggleRow({ icon, label, description, active, onToggle }: ToggleRowProps) {
return (
<button
onClick={onToggle}
className={`w-full flex items-center gap-3 p-3 rounded-sm transition-colors text-left
${active
? "bg-primary/15 text-primary ring-1 ring-primary/30"
: "hover:bg-muted text-foreground/70 hover:text-foreground"
}`}
aria-pressed={active}
>
<span className="flex-shrink-0 text-current">{icon}</span>
<div className="min-w-0">
<p className="text-sm font-medium leading-none mb-0.5">{label}</p>
<p className="text-[11px] text-muted-foreground leading-snug">{description}</p>
</div>
<span
className={`ml-auto flex-shrink-0 w-8 h-4 rounded-full transition-colors relative
${active ? "bg-primary" : "bg-border"}`}
aria-hidden="true"
>
<span className={`absolute top-0.5 w-3 h-3 rounded-full bg-white shadow transition-transform
${active ? "translate-x-4" : "translate-x-0.5"}`} />
</span>
</button>
);
}
export function AccessibilityPanel() {
const { darkMode, dyslexiaFont, highContrast, largeText,
toggleDark, toggleDyslexia, toggleHighContrast, toggleLargeText } = useAccessibility();
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
aria-label="Options d'accessibilité"
title="Options d'accessibilité"
>
<Accessibility className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
sideOffset={8}
className="w-72 p-3 space-y-1"
aria-label="Panneau d'accessibilité"
>
<p className="text-[10px] font-mono font-bold uppercase tracking-widest text-muted-foreground px-1 pb-1">
Accessibilité
</p>
<ToggleRow
icon={darkMode ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
label="Mode sombre"
description="Réduit la fatigue visuelle en faible luminosité"
active={darkMode}
onToggle={toggleDark}
/>
<ToggleRow
icon={<Type className="h-4 w-4" />}
label="Police dyslexie"
description="Espacement et police optimisés pour la lecture"
active={dyslexiaFont}
onToggle={toggleDyslexia}
/>
<ToggleRow
icon={<Contrast className="h-4 w-4" />}
label="Contraste élevé"
description="Améliore la lisibilité pour les malvoyants et daltoniens"
active={highContrast}
onToggle={toggleHighContrast}
/>
<ToggleRow
icon={<ZoomIn className="h-4 w-4" />}
label="Texte agrandi"
description="Augmente la taille de tous les textes (+20 %)"
active={largeText}
onToggle={toggleLargeText}
/>
<p className="text-[10px] text-muted-foreground/60 px-1 pt-1 leading-relaxed">
Vos préférences sont enregistrées localement.
</p>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,64 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from "react";
interface AccessibilityState {
darkMode: boolean;
dyslexiaFont: boolean;
highContrast: boolean;
largeText: boolean;
toggleDark: () => void;
toggleDyslexia: () => void;
toggleHighContrast: () => void;
toggleLargeText: () => void;
}
const AccessibilityContext = createContext<AccessibilityState | null>(null);
function load(key: string, fallback: boolean): boolean {
try {
const v = localStorage.getItem(key);
return v !== null ? v === "true" : fallback;
} catch {
return fallback;
}
}
function save(key: string, value: boolean) {
try { localStorage.setItem(key, String(value)); } catch { /* noop */ }
}
export function AccessibilityProvider({ children }: { children: React.ReactNode }) {
const [darkMode, setDarkMode] = useState(() => load("a11y-dark",
window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false
));
const [dyslexiaFont, setDyslexiaFont] = useState(() => load("a11y-dyslexia", false));
const [highContrast, setHighContrast] = useState(() => load("a11y-contrast", false));
const [largeText, setLargeText] = useState(() => load("a11y-large", false));
useEffect(() => {
const html = document.documentElement;
html.classList.toggle("dark", darkMode);
html.classList.toggle("dyslexia", dyslexiaFont);
html.classList.toggle("high-contrast", highContrast);
html.classList.toggle("large-text", largeText);
}, [darkMode, dyslexiaFont, highContrast, largeText]);
const toggleDark = useCallback(() => setDarkMode(v => { save("a11y-dark", !v); return !v; }), []);
const toggleDyslexia = useCallback(() => setDyslexiaFont(v => { save("a11y-dyslexia", !v); return !v; }), []);
const toggleHighContrast = useCallback(() => setHighContrast(v => { save("a11y-contrast", !v); return !v; }), []);
const toggleLargeText = useCallback(() => setLargeText(v => { save("a11y-large", !v); return !v; }), []);
return (
<AccessibilityContext.Provider value={{
darkMode, dyslexiaFont, highContrast, largeText,
toggleDark, toggleDyslexia, toggleHighContrast, toggleLargeText,
}}>
{children}
</AccessibilityContext.Provider>
);
}
export function useAccessibility() {
const ctx = useContext(AccessibilityContext);
if (!ctx) throw new Error("useAccessibility must be used inside AccessibilityProvider");
return ctx;
}
+129 -35
View File
@@ -162,52 +162,53 @@
--elevate-1: rgba(255,255,255, .04);
--elevate-2: rgba(255,255,255, .09);
/* Deep rich dark blue-black */
--background: 220 40% 6%;
--foreground: 40 20% 90%;
/* Fond sombre neutre */
--background: 200 20% 8%;
--foreground: 40 15% 90%;
--border: 220 30% 20%;
--input: 220 30% 20%;
--ring: 40 20% 90%;
--border: 200 15% 22%;
--input: 200 15% 22%;
--ring: 185 55% 58%;
--card: 220 40% 8%;
--card-foreground: 40 20% 90%;
--card-border: 220 30% 15%;
--card: 200 20% 10%;
--card-foreground: 40 15% 90%;
--card-border: 200 15% 18%;
--popover: 220 40% 8%;
--popover-foreground: 40 20% 90%;
--popover-border: 220 30% 15%;
--popover: 200 20% 10%;
--popover-foreground: 40 15% 90%;
--popover-border: 200 15% 18%;
--primary: 40 20% 90%;
--primary-foreground: 220 40% 6%;
/* Pétrol clair — visible sur fond sombre */
--primary: 185 55% 58%;
--primary-foreground: 200 40% 8%;
--secondary: 220 30% 15%;
--secondary-foreground: 40 20% 90%;
--secondary: 200 15% 16%;
--secondary-foreground: 40 15% 85%;
--muted: 220 30% 12%;
--muted-foreground: 220 15% 60%;
--muted: 200 15% 13%;
--muted-foreground: 200 12% 58%;
--accent: 220 30% 15%;
--accent-foreground: 40 20% 90%;
--accent: 200 15% 16%;
--accent-foreground: 40 15% 85%;
--destructive: 350 60% 45%;
--destructive: 12 60% 48%;
--destructive-foreground: 0 0% 100%;
--sidebar: 220 40% 6%;
--sidebar-foreground: 40 20% 90%;
--sidebar-border: 220 30% 20%;
--sidebar-primary: 40 20% 90%;
--sidebar-primary-foreground: 220 40% 6%;
--sidebar-accent: 220 30% 15%;
--sidebar-accent-foreground: 40 20% 90%;
--sidebar-ring: 40 20% 90%;
--sidebar: 200 20% 8%;
--sidebar-foreground: 40 15% 90%;
--sidebar-border: 200 15% 22%;
--sidebar-primary: 185 55% 58%;
--sidebar-primary-foreground: 200 40% 8%;
--sidebar-accent: 200 15% 16%;
--sidebar-accent-foreground: 40 15% 85%;
--sidebar-ring: 185 55% 58%;
--chart-1: 185 55% 58%;
--chart-2: 30 65% 55%;
--chart-3: 155 40% 50%;
--chart-4: 210 40% 55%;
--chart-5: 50 60% 52%;
--chart-1: 40 20% 90%;
--chart-2: 350 60% 45%;
--chart-3: 220 15% 60%;
--chart-4: 200 50% 50%;
--chart-5: 25 70% 50%;
--shadow-2xs: 0px 1px 0px 0px rgba(0,0,0,0.3);
--shadow-xs: 0px 1px 2px 0px rgba(0,0,0,0.3);
--shadow-sm: 0px 2px 4px 0px rgba(0,0,0,0.3);
@@ -303,6 +304,99 @@
}
}
/* ─── Accessibilité ────────────────────────────────────────── */
/* Anneau de focus renforcé pour la navigation clavier */
:focus-visible {
outline: 3px solid hsl(var(--primary));
outline-offset: 3px;
border-radius: 2px;
}
/* Mode dyslexie — police et espacement optimisés */
.dyslexia,
.dyslexia body {
font-family: 'Arial', 'Verdana', 'Trebuchet MS', sans-serif !important;
letter-spacing: 0.06em;
word-spacing: 0.14em;
line-height: 2;
}
.dyslexia p,
.dyslexia li,
.dyslexia blockquote {
max-width: 70ch;
word-break: keep-all;
hyphenation-character: none;
}
.dyslexia h1, .dyslexia h2, .dyslexia h3 {
letter-spacing: 0.03em;
word-spacing: 0.08em;
}
/* Texte agrandi (+20 %) */
.large-text {
font-size: 120%;
}
.large-text .text-xs { font-size: 0.9rem; }
.large-text .text-sm { font-size: 1rem; }
.large-text .text-base { font-size: 1.2rem; }
.large-text .text-lg { font-size: 1.35rem; }
.large-text .text-xl { font-size: 1.5rem; }
.large-text .text-2xl { font-size: 1.7rem; }
.large-text .text-3xl { font-size: 2rem; }
/* Contraste élevé — mode clair */
.high-contrast:not(.dark) {
--background: 0 0% 100%;
--foreground: 0 0% 0%;
--primary: 185 80% 18%;
--primary-foreground: 0 0% 100%;
--card: 0 0% 100%;
--card-foreground: 0 0% 0%;
--border: 0 0% 25%;
--input: 0 0% 25%;
--muted: 0 0% 93%;
--muted-foreground: 0 0% 15%;
--secondary: 0 0% 90%;
--secondary-foreground: 0 0% 0%;
--accent: 185 80% 14%;
--accent-foreground: 0 0% 100%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 0%;
}
/* Contraste élevé — mode sombre */
.high-contrast.dark {
--background: 0 0% 0%;
--foreground: 0 0% 100%;
--primary: 185 90% 68%;
--primary-foreground: 0 0% 0%;
--card: 0 0% 5%;
--card-foreground: 0 0% 100%;
--border: 0 0% 65%;
--input: 0 0% 65%;
--muted: 0 0% 10%;
--muted-foreground: 0 0% 80%;
--secondary: 0 0% 15%;
--secondary-foreground: 0 0% 100%;
--accent: 0 0% 15%;
--accent-foreground: 0 0% 100%;
--popover: 0 0% 5%;
--popover-foreground: 0 0% 100%;
}
/* Contraste élevé — renforcement général */
.high-contrast * {
text-shadow: none !important;
box-shadow: none !important;
}
.high-contrast a:not([class]) {
text-decoration: underline;
}
.high-contrast img {
filter: contrast(1.1);
}
/* ─── Impression / PDF ─────────────────────────────────────── */
@media print {
/* Masquer tout sauf le flyer */
+16 -1
View File
@@ -1,6 +1,6 @@
# Document d'Architecture Technique — La Voix du Peuple
**Version** : 1.2
**Version** : 1.3
**Date** : Avril 2026
**Statut** : En production (Replit), prêt pour auto-hébergement
@@ -13,6 +13,7 @@
| 1.0 | Avril 2026 | Version initiale |
| 1.1 | Avril 2026 | Ajout page Flyer QR, boutons Partager / PDF, `qrcode.react` |
| 1.2 | Avril 2026 | Palette pétrol neutre, textes de posture (expression vs. vérité) |
| 1.3 | Avril 2026 | Dark mode pétrol, panneau d'accessibilité (dyslexie, contraste, zoom) |
---
@@ -87,6 +88,20 @@
| `/transparence` | Fonctionnement de l'IA, données collectées, limites, posture éditoriale |
| `/flyer` | Flyer imprimable avec QR code configurable pour diffusion physique |
**Accessibilité** :
| Fonctionnalité | Mécanisme |
|----------------|-----------|
| Dark mode | Classes CSS `.dark` sur `<html>`, variables pétrol clair (`hsl(185 55% 58%)`) |
| Police dyslexie | Classe `.dyslexia` : Arial/Verdana, `letter-spacing 0.06em`, `line-height 2` |
| Contraste élevé | Classe `.high-contrast` : fond blanc/noir pur, ratio WCAG AA+ |
| Texte agrandi | Classe `.large-text` : `font-size 120%` global |
| Navigation clavier | `skip-link` "Aller au contenu principal" + anneau `:focus-visible` 3 px |
| ARIA | `aria-pressed` sur les toggles, `aria-label` sur tous les boutons d'action |
| Persistance | Préférences stockées dans `localStorage`, relues au chargement |
| Panneau | Composant `AccessibilityPanel` — icône dans la barre de navigation |
| Fournisseur d'état | `AccessibilityProvider` + `useAccessibility` hook (React Context) |
**Textes de posture** (infusés dans plusieurs sections) :
- Bandeau d'intro : "espace d'expression citoyenne, pas un sondage ni une vérité établie"
- Pied de synthèse : note italique discrète rappelant l'ancrage dans l'expertise de l'auteur
+28 -4
View File
@@ -1,6 +1,6 @@
# Document d'Exploitation — La Voix du Peuple
**Version** : 1.2
**Version** : 1.3
**Date** : Avril 2026
---
@@ -12,6 +12,7 @@
| 1.0 | Avril 2026 | Version initiale |
| 1.1 | Avril 2026 | Ajout section flyer QR, export PDF, partage horodaté |
| 1.2 | Avril 2026 | Palette pétrol neutre, textes de posture sur l'expression vs. vérité |
| 1.3 | Avril 2026 | Dark mode pétrol, panneau d'accessibilité (dyslexie, contraste, zoom) |
---
@@ -292,7 +293,30 @@ Après une purge, la synthèse se régénère automatiquement à la prochaine co
---
## 12. Modifier les textes de posture
## 12. Accessibilité — fonctionnement et personnalisation
Le panneau d'accessibilité est accessible via l'icône dans la barre de navigation (à gauche du drapeau). Quatre options sont disponibles :
| Option | Effet | Classe CSS sur `<html>` | Clé `localStorage` |
|--------|-------|------------------------|---------------------|
| Mode sombre | Fond sombre, pétrol clair | `.dark` | `a11y-dark` |
| Police dyslexie | Arial/Verdana, espacement élargi, interligne 2 | `.dyslexia` | `a11y-dyslexia` |
| Contraste élevé | Fond blanc/noir pur, contrastes WCAG AA+ | `.high-contrast` | `a11y-contrast` |
| Texte agrandi | +20 % sur tous les textes | `.large-text` | `a11y-large` |
Les préférences sont stockées dans le navigateur (localStorage) et relues automatiquement à chaque visite.
**Pour désactiver une option par code** (au déploiement, si souhaité), supprimer le `ToggleRow` correspondant dans `src/components/accessibility-panel.tsx`.
**Fichiers impliqués** :
- `src/hooks/use-accessibility.tsx` — contexte React, état, persistance
- `src/components/accessibility-panel.tsx` — interface utilisateur
- `src/index.css` — section `/* ─── Accessibilité */` — toutes les classes CSS
- `src/App.tsx``<AccessibilityProvider>`, skip-link, `id="main-content"`
---
## 13. Modifier les textes de posture
Les phrases de positionnement éditorial ("expression citoyenne, pas vérité établie", "auteur attaché à l'expertise") sont définies directement dans le code des composants React. Pour les modifier :
@@ -307,7 +331,7 @@ Après modification, reconstruire le frontend si en production (`pnpm build`), o
---
## 13. Modifier la palette de couleurs
## 14. Modifier la palette de couleurs
La couleur principale est définie dans `artifacts/voix-du-peuple/src/index.css`, ligne `--primary`. La valeur actuelle est `185 42% 28%` (pétrol foncé, politiquement neutre).
@@ -325,7 +349,7 @@ Toutes les occurrences de `--primary` dans le fichier CSS s'appliquent automatiq
---
## 14. Contacts et ressources
## 15. Contacts et ressources
- Documentation Mistral : https://docs.mistral.ai
- PostgreSQL : https://www.postgresql.org/docs/
+1 -1
View File
@@ -5,7 +5,7 @@
**Hébergement** : Replit (dev) / Auto-hébergeable (RockyLinux, Debian)
**Dépôt** : `voix-du-peuple` (Gitea)
**Statut** : Actif — avril 2026
**Version doc** : 1.2
**Version doc** : 1.3
---