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:
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user