feat(a11y): WCAG AA polish on the site onboarding flow
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled

- index.css: add visible :focus-visible rings for .site-card-button
  and .site-button so keyboard users can see where focus lives
- SiteCard: drop outline:none, add aria-pressed and aria-label so
  screen readers announce selection state
- SiteButton: add the .site-button class for the shared focus ring
- SiteDisclaimerScreen: keyboard skip via Enter / Space / Escape, a
  role="region" + aria-label wrapper and aria-live="polite" on the
  message; honour prefers-reduced-motion on the fade
- IntroVideoPlayer: role="region" with a skip hint in aria-label,
  preload="auto", and aria-hidden on the decorative caption span
This commit is contained in:
Tom Boullay
2026-05-30 18:44:03 +02:00
parent 07b09c22af
commit 970adf4853
5 changed files with 52 additions and 32 deletions
+1
View File
@@ -21,6 +21,7 @@ export function SiteButton({
onMouseDown={() => setIsPressed(true)} onMouseDown={() => setIsPressed(true)}
onMouseUp={() => setIsPressed(false)} onMouseUp={() => setIsPressed(false)}
onMouseLeave={() => setIsPressed(false)} onMouseLeave={() => setIsPressed(false)}
className="site-button"
style={{ style={{
display: "inline-flex", display: "inline-flex",
padding: "12px 20px", padding: "12px 20px",
+7 -13
View File
@@ -22,23 +22,18 @@ export function SiteCard({
return "#b8b8b8"; return "#b8b8b8";
}; };
const getBorder = (): string => { const borderColor = selected ? "#a8d5a2" : "rgba(255, 255, 255, 0.55)";
if (selected) return "3px solid #a8d5a2";
if (isSituation) return "3px solid rgba(255, 255, 255, 0.55)";
if (disabled) return "3px solid rgba(255, 255, 255, 0.55)";
return "3px solid rgba(255, 255, 255, 0.55)";
};
const getTextColor = (): string => { const textColor = disabled ? "rgba(77, 77, 77, 0.72)" : "#4d4d4d";
if (disabled) return "rgba(77, 77, 77, 0.72)";
return "#4d4d4d";
};
return ( return (
<button <button
type="button" type="button"
onClick={onSelect} onClick={onSelect}
disabled={disabled} disabled={disabled}
aria-pressed={selected}
aria-label={label}
className="site-card-button"
style={{ style={{
width: isSituation width: isSituation
? "clamp(220px, 24vw, 300px)" ? "clamp(220px, 24vw, 300px)"
@@ -46,21 +41,20 @@ export function SiteCard({
height: isSituation height: isSituation
? "clamp(48px, 6vw, 60px)" ? "clamp(48px, 6vw, 60px)"
: "clamp(140px, 18vw, 180px)", : "clamp(140px, 18vw, 180px)",
border: getBorder(), border: `3px solid ${borderColor}`,
background: getBackground(), background: getBackground(),
cursor: disabled ? "not-allowed" : "pointer", cursor: disabled ? "not-allowed" : "pointer",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
transition: "all 0.15s ease", transition: "all 0.15s ease",
outline: "none",
flexShrink: 0, flexShrink: 0,
}} }}
> >
{!imagePath && ( {!imagePath && (
<span <span
style={{ style={{
color: getTextColor(), color: textColor,
fontSize: isSituation fontSize: isSituation
? "clamp(14px, 1.8vw, 22px)" ? "clamp(14px, 1.8vw, 22px)"
: "clamp(10px, 1.5vw, 14px)", : "clamp(10px, 1.5vw, 14px)",
+21 -8
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useSiteStore } from "@/managers/stores/useSiteStore"; import { useSiteStore } from "@/managers/stores/useSiteStore";
import { usePrefersReducedMotion } from "@/hooks/ui/usePrefersReducedMotion";
const DISCLAIMER_TEXT = const DISCLAIMER_TEXT =
"Ce site a été conçu pour être utilisé sur ordinateur.\nPour une meilleure expérience, assurez-vous d'avoir une bonne connexion internet et une machine performante."; "Ce site a été conçu pour être utilisé sur ordinateur.\nPour une meilleure expérience, assurez-vous d'avoir une bonne connexion internet et une machine performante.";
@@ -7,13 +8,15 @@ const DISCLAIMER_TEXT =
const TEXT_DISPLAY_DURATION = 5000; const TEXT_DISPLAY_DURATION = 5000;
const FADE_OUT_DURATION = 1000; const FADE_OUT_DURATION = 1000;
const TRANSITION_DELAY = 250; const TRANSITION_DELAY = 250;
const SKIP_KEYS = new Set(["Enter", " ", "Escape"]);
/** /**
* Screen 0: Disclaimer * Screen 0: Disclaimer
*/ */
export function SiteDisclaimerScreen(): React.JSX.Element { export function SiteDisclaimerScreen(): React.JSX.Element {
const setStep = useSiteStore((state) => state.setStep); const setStep = useSiteStore((state) => state.setStep);
const [textOpacity, setTextOpacity] = useState(0); const prefersReducedMotion = usePrefersReducedMotion();
const [textOpacity, setTextOpacity] = useState(prefersReducedMotion ? 1 : 0);
const hasSkipped = useRef(false); const hasSkipped = useRef(false);
const handleSkip = useCallback(() => { const handleSkip = useCallback(() => {
@@ -23,33 +26,40 @@ export function SiteDisclaimerScreen(): React.JSX.Element {
}, [setStep]); }, [setStep]);
useEffect(() => { useEffect(() => {
// Fade in text
const fadeInTimeout = window.setTimeout(() => { const fadeInTimeout = window.setTimeout(() => {
setTextOpacity(1); setTextOpacity(1);
}, 100); }, 100);
// Start fade out after display duration
const fadeOutTimeout = window.setTimeout(() => { const fadeOutTimeout = window.setTimeout(() => {
setTextOpacity(0); setTextOpacity(0);
}, TEXT_DISPLAY_DURATION); }, TEXT_DISPLAY_DURATION);
// Transition to welcome after fade out + delay
const transitionTimeout = window.setTimeout( const transitionTimeout = window.setTimeout(
() => { handleSkip,
handleSkip();
},
TEXT_DISPLAY_DURATION + FADE_OUT_DURATION + TRANSITION_DELAY, TEXT_DISPLAY_DURATION + FADE_OUT_DURATION + TRANSITION_DELAY,
); );
const handleKeyDown = (event: KeyboardEvent): void => {
if (SKIP_KEYS.has(event.key)) {
event.preventDefault();
handleSkip();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => { return () => {
window.clearTimeout(fadeInTimeout); window.clearTimeout(fadeInTimeout);
window.clearTimeout(fadeOutTimeout); window.clearTimeout(fadeOutTimeout);
window.clearTimeout(transitionTimeout); window.clearTimeout(transitionTimeout);
window.removeEventListener("keydown", handleKeyDown);
}; };
}, [handleSkip]); }, [handleSkip]);
return ( return (
<div <div
role="region"
aria-label="Avertissement"
onClick={handleSkip} onClick={handleSkip}
style={{ style={{
position: "fixed", position: "fixed",
@@ -63,6 +73,7 @@ export function SiteDisclaimerScreen(): React.JSX.Element {
}} }}
> >
<p <p
aria-live="polite"
style={{ style={{
color: "#F2F2F2", color: "#F2F2F2",
textAlign: "center", textAlign: "center",
@@ -72,7 +83,9 @@ export function SiteDisclaimerScreen(): React.JSX.Element {
lineHeight: 1.6, lineHeight: 1.6,
maxWidth: 800, maxWidth: 800,
opacity: textOpacity, opacity: textOpacity,
transition: `opacity ${FADE_OUT_DURATION}ms ease-in-out`, transition: prefersReducedMotion
? "none"
: `opacity ${FADE_OUT_DURATION}ms ease-in-out`,
whiteSpace: "pre-wrap", whiteSpace: "pre-wrap",
}} }}
> >
+11 -11
View File
@@ -1,13 +1,12 @@
import { useCallback, useRef, useEffect } from "react"; import { useCallback, useEffect, useRef } from "react";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
const INTRO_VIDEO_PATH = "/cinematics/intro.mp4"; const INTRO_VIDEO_PATH = "/cinematics/intro.mp4";
const SKIP_KEYS = new Set(["Enter", " "]);
/** /**
* Full-screen video player for intro cinematic * Full-screen video player for the intro cinematic.
* - Plays intro.mp4 in fullscreen * Advances to the dialogue-intro step when the video ends or the user skips.
* - Automatically advances to dialogue-intro step when video ends
* - Allows skipping with Enter/Space/Click
*/ */
export function IntroVideoPlayer(): React.JSX.Element { export function IntroVideoPlayer(): React.JSX.Element {
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
@@ -18,16 +17,13 @@ export function IntroVideoPlayer(): React.JSX.Element {
}, [setIntroStep]); }, [setIntroStep]);
const handleSkip = useCallback(() => { const handleSkip = useCallback(() => {
if (videoRef.current) { videoRef.current?.pause();
videoRef.current.pause();
}
setIntroStep("dialogue-intro"); setIntroStep("dialogue-intro");
}, [setIntroStep]); }, [setIntroStep]);
// Handle keyboard skip (Enter/Space)
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key === "Enter" || event.key === " ") { if (SKIP_KEYS.has(event.key)) {
event.preventDefault(); event.preventDefault();
handleSkip(); handleSkip();
} }
@@ -39,6 +35,8 @@ export function IntroVideoPlayer(): React.JSX.Element {
return ( return (
<div <div
role="region"
aria-label="Vidéo d'introduction. Appuyez sur Entrée pour passer."
onClick={handleSkip} onClick={handleSkip}
style={{ style={{
position: "fixed", position: "fixed",
@@ -56,6 +54,7 @@ export function IntroVideoPlayer(): React.JSX.Element {
src={INTRO_VIDEO_PATH} src={INTRO_VIDEO_PATH}
autoPlay autoPlay
playsInline playsInline
preload="auto"
onEnded={handleVideoEnd} onEnded={handleVideoEnd}
style={{ style={{
width: "100%", width: "100%",
@@ -64,6 +63,7 @@ export function IntroVideoPlayer(): React.JSX.Element {
}} }}
/> />
<span <span
aria-hidden="true"
style={{ style={{
position: "absolute", position: "absolute",
bottom: 32, bottom: 32,
+12
View File
@@ -44,6 +44,18 @@ select {
font: inherit; font: inherit;
} }
/* Site onboarding — accessible focus rings (WCAG 2.4.7) */
.site-card-button:focus-visible,
.site-button:focus-visible {
outline: 3px solid #ffffff;
outline-offset: 3px;
box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.55);
}
.site-card-button[aria-pressed="true"]:focus-visible {
outline-color: #a8d5a2;
}
canvas { canvas {
display: block; display: block;
} }